Skip to content

fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493) - #194

Open
AakashHotchandani wants to merge 9 commits into
mainfrom
fix/sdk-7493-queue-skip-reports-until-drain
Open

fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)#194
AakashHotchandani wants to merge 9 commits into
mainfrom
fix/sdk-7493-queue-skip-reports-until-drain

Conversation

@AakashHotchandani

@AakashHotchandani AakashHotchandani commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What is this about?

Follow-up to #178, which was a partial fix. That PR fixed the case where the skipped test is last; a skip in the middle of a spec still renders "In Progress". The customer confirmed it still reproduces on 9.35.3 (SDK-7493 comment 2359027):

describe('Test A @crossPlatform', () => {
  it('TC-5944 Test 1', ...)        // runs
  it.skip('TC-5947 Test 2', ...)   // skipped, in the MIDDLE
  it('TC-5948 Test 3', ...)        // runs
})

wdio does not await onTestSkip, so emitting a skip's events inline let them interleave with a running test's. Both tests resolve through one per-worker tracked-instance slot (TestFramework.setTrackedInstance keys on a single per-worker context id), so:

  1. Test 3 beforeTest -> instance I3 (uuid 67421cd8); tracked slot = I3
  2. Test 3 TEST/PRE sent
  3. Skip chain starts mid-Test-3 -> instance I2 (uuid 007ad40f); tracked slot = I2
  4. Skip TEST/PRE sent
  5. Test 3 afterTest fires while the chain is in flight -> _cliTestUuids (service.ts) restores Test 3's uuid onto getTrackedInstance()now I2, the skip's instance
  6. Skip's TEST/POST, carrying the skipped result, stashes under 67421cd8 -> closes Test 3 as skipped
  7. Test 3's own TEST/POST also resolves to I2 -> same key -> replaced
  8. Test 2 (007ad40f) never gets a finish -> "In Progress" until Test Hub's ~60-min idle reap

Per the SDK↔TRA event contract (one TestRunStarted + TestRunFinished pair per test execution, status ∈ passed|failed|skipped), TRA is behaving correctly here: it received a start with no finish. There is no TRA-side fix.

The fix: reportSkippedTest now only queues a descriptor; drainSkipReports() emits it. That drain runs from service.after() — placed before the deferred flush by #178 — where no test is in flight, so the tracked slot cannot be hijacked mid-test and _cliTestUuids cannot write onto a skip's instance. It removes the interleave rather than compensating for it.

Also hardens the deferral (defence in depth, neither fixes this alone): pendingTestFinish becomes a uuid-keyed map so a stash can never silently evict another test's pending finish, and the flush pins the uuid captured at defer time instead of re-reading a possibly-rewritten one at send time.

Behaviour change reviewers should know

Skipped tests are now reported at end of run rather than in place, so their timestamps cluster at after(). Duration is meaningless for a skipped test, but the ordering on the dashboard changes. Hook-cascade skips (reportSuiteSkipped, called from the awaited afterHook) go through the same queue.

Verification

Real WDIO 9 + Mocha runs on Automate (chrome/Win11), the customer's exact spec. Every build read back from /ext/v1/builds/<uuid>/testRuns plus the rollup, not just the console:

build Test 1 Test 2 (skip) Test 3 rollup
tist6o8g stock 9.35.3 passed pending passed in progress 1
cv2s5gqa this PR passed skipped passed passed 2, skipped 1, in progress 0

Wire: three matched TEST/PRE+TEST/POST pairs (ddf724d9, 7a32f3c0, 612b6beb) — no orphaned start, and no finish carrying another test's uuid. Before the fix there were only two pairs and they were mismatched (PRE 35291764 / POST 2e607e19).

#178's shapes re-verified — no regression. This changes when skip events are emitted, so both were re-run on this build:

  • trailing it.skip (build r3atsy7i): passed 1, skipped 1, both terminal
  • pure describe.skip (build 4vropsag): skipped 2, both terminal

Tests. 3 new cases in tests/cli/skipReporter.midRunInterleave.test.ts, asserting that nothing reaches the tracker at report time, that a skip does not interleave between a running test's PRE and POST, and that every queued skip gets both a start and a finish. Two of the three were confirmed to fail on the unfixed source — a test that passes either way would be worthless for this defect.

Updated the suites that encoded the old contract: skipReporter.test.ts (5 cases now drain before asserting) and testHubModule.deferredFinish.test.ts (the "not re-stashed" assertion reads the map's size instead of a null slot).

Full package suite: 75 failed / 1147 passed on a clean tree vs 77 / 1148 with this PR — the same 8 pre-existing failing files, none of them touching the changed code. Those suites are network-dependent and their count varies run to run. tsc -p tsconfig.prod.json --noEmit and eslint clean.

Note on how this was found

Three earlier fix attempts failed and are worth recording so nobody repeats them: forcing a fresh instance for the skip chain (a no-op — each test already gets its own instance object, 3 created in both broken and patched runs); restoring the tracked slot after the chain (too late — the damage happens during it); and the uuid-keyed map alone (improved the wire from 4 to 6 sends but still mis-closed Test 3 as skipped, which is what exposed that identity was already corrupted upstream of the deferral).

Related Jira task/s

SDK-7493

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 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.

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

  • skipReporter.reportSkippedTest now queues a QueuedSkip descriptor instead of emitting inline; drainSkipReports() drains the queue one at a time. wdio does not await onTestSkip, so inline emission interleaved a skip's events with a running test's — and both share one per-worker tracked-instance slot.
  • The concrete loss: the skip's INIT_TEST repointed the tracked slot mid-test, the live test's afterTest then restored its own uuid onto the skip's instance via service.ts _cliTestUuids, and both tests' TEST/POSTs collapsed onto one uuid — one TestRunFinished never sent (test reaped at ~60 min as "In Progress"), the other closed with the wrong result.
  • The drain already runs from service.after() before the deferred flush (fix(cli): drain skip reports before flushing the deferred test finish (SDK-7493) #178), and no test is in flight there, so queuing is sufficient to remove the interleave.
  • TestHubModule.pendingTestFinish is now the uuid-keyed pendingTestFinishes map: a stash cannot evict another test's pending finish, and flushPendingTestFinishEvent drains the whole batch. The flush also pins the uuid captured at defer time via a new uuid field on sendTestFrameworkEvent's stateOverride, so a deferred send cannot close the wrong test_run if the instance's uuid was rewritten meanwhile.
  • Skipped tests are consequently reported at end of run; their timestamps cluster at after(). Hook-cascade skips (reportSuiteSkipped) use the same queue.
  • fix(cli): drain skip reports before flushing the deferred test finish (SDK-7493) #178 remains correct for the trailing-skip case and both its shapes were re-verified on real builds against this branch.

Checklist

  • Ready to review
  • Has it been tested locally?

PR Validations

Run Tests: Comment RUN_TESTS to trigger sanity tests.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed skipped tests remaining marked as “In Progress” in BrowserStack builds.
    • Skipped tests are now reported at run completion and grouped together correctly.
    • Improved reporting for multiple interleaved test completions without mixing test details.
    • Ensured each skipped test receives matching start and completion events.
  • Tests

    • Added coverage for concurrent test finishes, queued skipped-test reporting, deduplication, and nested suites.

… lost (SDK-7493)

Follow-up to #178, which fixed only the case where the skip is LAST. A skip in the
middle of a spec still rendered "In Progress" on 9.35.3.

wdio does not await onTestSkip, so emitting a skip's events inline let them interleave
with a running test's. Both resolve through ONE per-worker tracked-instance slot, so
the skip's INIT_TEST repointed that slot mid-test; the live test's afterTest then
restored ITS uuid onto the skip's instance (service.ts _cliTestUuids), and from there
both tests' TEST/POSTs collapsed onto a single uuid. One TestRunFinished was never
sent -- that test stayed "In Progress" until Test Hub's ~60-min idle reap -- and the
survivor was closed with the wrong result.

reportSkippedTest now only QUEUES a descriptor; drainSkipReports() emits it. That drain
runs from service.after(), where no test is in flight, so the tracked slot cannot be
hijacked mid-test and _cliTestUuids cannot write onto a skip's instance.

Also hardens the deferral itself: pendingTestFinish becomes a uuid-keyed map so a stash
can never silently evict another test's pending finish, and the flush pins the uuid
captured at defer time rather than re-reading a possibly-rewritten one at send time.
Neither fixes this alone; they stop a future interleave degrading into lost data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

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: 5dfdab57-ff85-44ff-a8c0-b6a47da0cddc

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
📝 Walkthrough

Walkthrough

The BrowserStack service now queues skipped-test reports and drains them after active test events. Deferred test finishes use UUID-keyed storage and preserve the UUID captured when each finish was deferred.

Changes

Skipped-test reporting

Layer / File(s) Summary
UUID-keyed deferred finishes
packages/browserstack-service/src/cli/modules/testHubModule.ts, packages/browserstack-service/tests/cli/modules/testHubModule.deferredFinish.test.ts
TestHubModule retains deferred finishes by UUID, drains each entry with retries, and sends each finish with its captured UUID. Tests cover interleaving, replacement, UUID capture, and retry exhaustion.
Serialized skipped-test emission
packages/browserstack-service/src/cli/skipReporter.ts, packages/browserstack-service/tests/cli/skipReporter.midRunInterleave.test.ts, packages/browserstack-service/tests/cli/skipReporter.test.ts, .changeset/pr-194.md
skipReporter queues skipped tests and emits their event sequences sequentially. Tests cover draining, event ordering, deduplication, nested suites, and mid-run interleaving. The changeset records the patch release.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant TestRunner
  participant skipReporter
  participant TestHubModule
  TestRunner->>skipReporter: Report skipped test
  skipReporter->>skipReporter: Queue skipped test
  skipReporter->>TestHubModule: Drain skipped-test events
  TestHubModule-->>skipReporter: Send INIT_TEST, TEST, LOG_REPORT, and TEST/POST
Loading

Merge Risk: 🟡 Moderate · up to 7ad0b

A transient tracking failure while reporting a skipped test can leave that test shown as In Progress. The terminal lifecycle event should still be attempted before merging.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: queueing skip reports until the drain to prevent mid-run skipped tests from being lost. It also includes the related issue identifier.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sdk-7493-queue-skip-reports-until-drain

A rabbit queues each skipped test,
Then drains the list in order.
UUIDs stay with their finishes,
No event hops the border.
The test hub gets the full report,
And every carrot lands in place.

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

@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 2 issues — the queue-and-drain correctly eliminates the mid-run skip interleave and #178 is not regressed; two grounded gaps remain (no direct regression test for the uuid-keyed map / pinned-uuid hardening; queued skips are silently dropped if service.after() never runs on abnormal teardown).

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

The root fix (defer every CLI-flow skip to drainSkipReports(), run from after() before the #178 deferred flush, where no test is in flight) is sound and well-verified (real builds + wire evidence + deterministic unit tests). Findings are non-blocking: a test-coverage gap on the second defense, a crash-path graceful-degradation tradeoff to acknowledge, and minor docs/hardening notes. No proto/gRPC/Binary change — wdio-only, no paired PR expected.

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

Generated by Automated SDK PR review.

// flush call sites and can drop a newer test's finish (SDK-7265 review #1).
// SDK-7493: the single `pendingTestFinish` slot is now a uuid-keyed map, so "not
// re-stashed" means the map is empty rather than the slot being null.
expect((testHubModule as unknown as { pendingTestFinishes: Map<string, unknown> }).pendingTestFinishes.size).toBe(0)

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 — [TESTING] uuid-keyed map + pinned-uuid flush have no direct regression test

Problem

The PR adds two "defence in depth" hardenings to TestHubModule:

  1. pendingTestFinish (single slot) → pendingTestFinishes (uuid-keyed Map), so a second stash can no longer silently evict another test's pending finish.
  2. flushPendingTestFinishEvent now pins the uuid captured at defer time (sendTestFrameworkEvent(args, { …, uuid })), so a deferred send closes the correct test_run even if the instance's live uuid was rewritten meanwhile.

But the only testHubModule test change is this one line — flipping the old pendingTestFinish === null assertion to pendingTestFinishes.size === 0. There is no test that asserts the two new properties directly:

  • that two finishes with different uuids stashed against the same tracked instance both survive (the anti-eviction property — the whole reason the map replaced the slot), and
  • that a flush sends the defer-time uuid rather than a rewritten live uuid (the pin).

skipReporter.midRunInterleave.test.ts exercises the skipReporter layer with a mocked trackEvent; it never reaches TestHubModule's map or sendTestFrameworkEvent. So the map/pin behavior is covered only indirectly (adapted deferred-finish test) and by the manual real-build wire verification — a future refactor could silently break the second defense with the suite still green.

Suggested Fix

Add a focused TestHubModule unit test that:

  • drives two TEST/POST defers whose instances report the same getRef()/tracked context but different KEY_TEST_UUID, then asserts pendingTestFinishes.size === 2 and that a single flushPendingTestFinishEvent() emits both finishes, each carrying its own pinned uuid; and
  • defers a finish under uuid A, mutates the instance's live KEY_TEST_UUID to B, flushes, and asserts the sent event's top-level uuid === 'A' (pin wins over live read).

Confidence: 🟢 Objectively verifiable from the diff — no added/changed testHubModule test asserts two coexisting entries or the pinned-uuid send.

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 facc04e.

Added three focused cases to testHubModule.deferredFinish.test.ts that hit the module directly rather than through skipReporter:

  • two finishes stashed against the SAME instance under different uuids both survive — the anti-eviction property the map exists for. Stashes one finish, rewrites __uuid on the same instance object (exactly what an interleave leaves behind), stashes again, asserts pendingTestFinishes.size === 2 and that both uuids reach the wire.
  • flushes the uuid captured at DEFER time, not the instance uuid at send time — rewrites the instance uuid after the stash and asserts the sent payload still carries the defer-time uuid.
  • re-stashing the SAME uuid replaces rather than duplicating — guards the LOG_REPORT/POST re-entry so one test is not finished twice.

All three fail against the pre-fix single-slot module (verified by checking out testHubModule.ts from HEAD~1: 4 failed / 5 passed, the fourth being the adapted assertion). Related suites now 23/23.

})
// SDK-7493: queue only — see the QueuedSkip docs above. Emitting here would interleave
// this skip's events with whatever test is currently running.
queuedSkips.push({ framework, test, result, 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.

⚠️ Warning — [GRACEFUL-DEGRADATION] Queued skips are silently dropped if service.after() never runs

Problem

reportSkippedTest now only pushes a QueuedSkip; nothing is emitted until drainSkipReports(), which is called exclusively from service.after() (service.ts:661). Every CLI-flow skip path funnels here — static it.skip (reporter.ts:243), the failed-hook cascade (service.ts:490) and the bail cascade (service.ts:633) all route through reportSuiteSkipped → reportSkippedTest. That is the correct design for eliminating the interleave, but it also means:

  • If the worker is killed or crashes before after() runs (SIGKILL, OOM, an unhandled teardown error that skips after()), all queued skips are lost with no send attempt at all. The removed inline path at least attempted a send during the run.

This is an acceptable tradeoff — the old inline path was itself broken (that is the bug being fixed), and an aborted worker already leaves in-progress runs to Test Hub's ~60-min idle reap regardless — but it is a real behavior change on the crash path (skips definitely lost vs. attempted) and is currently undocumented. Per the graceful-degradation pattern in the review knowledge, instrumentation failure must not worsen reporting silently.

Suggested Fix

No code change is required for correctness. Either:

  • add a one-line code comment at the queue site noting that queued skips depend on after() running and are dropped on abnormal teardown (accepted, since a crashed worker is reaped anyway); or
  • if worth hardening, also drain from a afterSession/process-exit safety hook the way the Direct flow's finalizeOrphanedRuns() closes orphans at afterSuite.

Confidence: 🟡 The drop-on-crash is objectively true (drain only runs from after()); whether it is worth hardening vs. documenting is a judgment call on wdio teardown guarantees.

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 — documented in facc04e, no code change, as the comment suggests.

Added at the queue site:

// Tradeoff: delivery now depends on service.after() running. If the worker dies before it
// (SIGKILL, OOM, a teardown error that skips after()), queued skips are dropped with no
// send attempted, where the old inline path would at least have tried. Accepted because
// the inline path is the bug being fixed, and an aborted worker already leaves its
// in-progress test runs to Test Hub's idle reap regardless.

Agreed this is a real behaviour change on the crash path and worth stating rather than leaving implicit. Worth adding that the inline path's "attempt" was not reliable delivery either — on the mid-run shape it emitted a TestRunStarted and then lost the matching finish, which is strictly worse than not reporting at all: the test then renders "In Progress" for ~60 minutes instead of simply being absent. So on the abnormal-teardown path we trade a possible partial report for no report.

Comment thread .changeset/pr-194.md Outdated
"@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.

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 — [DOCS] Changeset omits the end-of-run ordering behavior change

Problem

The PR body explicitly flags a reviewer-visible behavior change: skipped tests are now reported at end of run (their timestamps cluster at after()), so their ordering on the dashboard changes. The internal release notes capture this, but the customer-facing changeset only describes the bug being fixed — nothing about the ordering shift. A customer who scripts against Test Hub ordering, or who eyeballs the dashboard, may notice skipped tests grouped at the end rather than in source order.

Duration is meaningless for a skipped test and TRA status rollup (passed/failed/skipped counts) is order-independent, so there is no data-integrity impact — this is purely a note-completeness point.

Suggested Fix

Optionally add a sentence to the changeset, e.g. "Skipped tests are now reported when the run finishes, so they appear grouped at the end of the build rather than in source order." Keep it only if the team surfaces this class of note to customers.

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 f97c969.

.changeset/pr-194.md is auto-generated by the bot from the PR template's customer-facing section, so I updated both so a regeneration stays consistent. The note now reads:

... Skipped tests are now reported when the run finishes, so they appear grouped at the end of the build rather than in source order.

Agreed there is no data-integrity impact — the status rollup is order-independent and duration is meaningless for a skip — but it is customer-visible on the dashboard, so it belongs in the customer-facing note rather than only the internal one.

// 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()

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] Pinned uuid covers the top-level field but not the uuid embedded in eventJson

Problem

sendTestFrameworkEvent now pins the uuid via stateOverride?.uuid for the top-level uuid field (line 210). However eventJson (built a few lines below from Object.fromEntries(testData)) is still serialized from the instance's live data map — which, in the exact interleave scenario the pin is meant to defend against, could carry a rewritten KEY_TEST_UUID inside the blob. So the top-level routing field and the uuid inside the payload could disagree.

In practice this is harmless because the queue-and-drain already removes the interleave, so the instance's live uuid is no longer rewritten mid-flight and the two agree. But it is worth being explicit: the pin fully protects only if the Binary keys test_run closure on the top-level uuid field, not on a uuid read out of eventJson. The real-build wire evidence (three matched PRE+POST pairs, no finish carrying another test's uuid) supports that the top-level field is authoritative.

Suggested Fix

No change needed if the Binary routes on the top-level uuid. Worth a one-time confirmation with the Binary side that test_run finish keys on the request's uuid field and not on a uuid parsed from eventJson; if the latter, also stamp the pinned uuid into the serialized data before send.

Confidence: 🟡 Depends on Binary-side routing semantics not visible in this repo; empirically supported by the PR's wire verification.

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.

Good catch on the ambiguity, but no change needed — the binary keys test_run closure on the top-level uuid field. I can confirm that empirically rather than by asking, because a failed intermediate attempt on this ticket produced exactly the disagreement you describe.

That attempt applied the uuid-keyed map without the queue-and-drain, so the interleave was still live and the two did diverge. Its final send was:

TEST/POST  top-level uuid=67421cd8   <- Test 3's uuid
           eventJson  from the SKIP's instance (skipped result)

Build v0rger0sk came back with Test 3 (67421cd8) = skipped and Test 2 (007ad40f) still pending. So the binary closed the run named by the top-level uuid and took the result from eventJson — i.e. field 5 is authoritative for routing, field 6 for payload. Had it parsed a uuid out of eventJson, it would have closed 007ad40f instead.

So the pin fully protects the routing field, which is the one that decides whether a test terminalizes. You are right that eventJson could still carry a stale KEY_TEST_UUID; with the queue in place the instance uuid is no longer rewritten mid-flight, so the two agree on the normal path. I have deliberately not stamped the pinned uuid into the serialized blob — that would be redundant with the queue and is the same class of unnecessary belt-and-braces flagged in the anti-pattern #26 comment on this PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Considered — an author comment can't ground its own finding (Rule A); packages/browserstack-service/src/cli/modules/testHubModule.ts·TestHubModule.sendTestFrameworkEvent still needs a non-author reviewer's explanatory confirmation.

— SDK PR Review Agent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Considered — an author comment can't ground its own finding (Rule A); packages/browserstack-service/src/cli/modules/testHubModule.ts·TestHubModule.sendTestFrameworkEvent still needs a non-author reviewer's explanatory confirmation.

— SDK PR Review Agent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Considered — an author comment can't ground its own finding (Rule A); packages/browserstack-service/src/cli/modules/testHubModule.ts·TestHubModule.sendTestFrameworkEvent still needs a non-author reviewer's explanatory confirmation.

— SDK PR Review Agent

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.

Understood, and I agree an author's own assertion should not close a finding. Flagging it as open for a non-author reviewer rather than treating my reply as resolution.

To make that confirmation cheap, the claim is reproducible from artifacts rather than from my reasoning — a reviewer can check it without re-deriving anything:

  • Build v0rger0skcw162gkuf8hw6obgqbo5r4fiv3ppres (an intermediate attempt on this ticket, map applied without the queue, so the interleave was still live).
  • Its last TEST/POST went out with top-level uuid=67421cd8 (Test 3) while its eventJson came from the skip's instance.
  • /ext/v1/builds/v0rger0sk.../testRuns shows Test 3 (67421cd8) = skipped, Test 2 (007ad40f) = pending.

If the binary parsed a uuid out of eventJson, it would have closed 007ad40f. So field 5 routes, field 6 supplies the payload.

The safest independent confirmation is from the Binary side rather than from me: if someone on that team can confirm test_run closure keys on TestFrameworkEventRequest.uuid, that closes it definitively. Happy to make the change if it turns out otherwise — it would be a one-line stamp of the pinned uuid into the serialized data before send.

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.

Following up on my earlier reply here — I was wrong and this finding was correct. The binary routes on event.test_uuid parsed out of event_json (webdriverio/index.js:83 + mocha/index.js:159), not the top-level uuid. My build evidence could not discriminate, because both fields were serialized from the same instance and therefore agreed. Fixed in a3956a7 — the pinned uuid is now applied to the serialized data too. Details in the PR comment.

* idle reap. A map cannot evict: every deferred finish is delivered, each under its own uuid.
*/
private pendingTestFinish: { args: Record<string, unknown> } | null = null
private pendingTestFinishes: Map<string, { args: Record<string, unknown>, uuid: string }> = new Map()

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 — [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.

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 facc04e. Added the line you suggested, on the map's doc block:

* 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.

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 eventJson comment, I have not extended the pin into the serialized payload — that genuinely would be a guard for something the queue eliminates.

AakashHotchandani and others added 4 commits September 10, 2026 18:57
…DK-7493 review)

Addresses PR review findings.

[TESTING] The map and the defer-time uuid pin had no direct regression test — only an
adapted assertion and the real-build wire evidence. Adds three focused cases:
two finishes stashed against the SAME instance under different uuids both survive
(the anti-eviction property the map exists for); a flush sends the defer-time uuid, not
the instance uuid at send time; and a same-uuid re-stash replaces rather than duplicating
(the LOG_REPORT/POST re-entry). All three fail against the pre-fix single-slot module.

[GRACEFUL-DEGRADATION] Documents at the queue site that delivery now depends on
service.after() running: on abnormal teardown queued skips are dropped with no send
attempted, where the inline path would have tried.

[MAINTAINABILITY] States explicitly that the queue-and-drain is the primary fix and the
map/pin are retained defence-in-depth, so neither reads as load-bearing to a future
maintainer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eview)

Skipped tests are now reported at drain time, so they appear grouped at the end of the
build rather than in source order. Customer-visible, so it belongs in the changeset and
not only the internal notes.

Co-Authored-By: Claude Opus 5 (1M context) <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 — Re-review at 52da8be5

Verdict: ✅ Good to go — every prior finding is addressed. The re-review delta (892399352da8be5) adds the direct regression tests that were missing and, on the two production files, adds explanatory comments only (defence-in-depth note + accepted-tradeoff note). No production logic changed, and the core fix is intact.

Summary: 0 critical · 0 warnings · 0 open suggestions across 6 files reviewed.

The core fix still holds at this head: reportSkippedTest only queues; drainSkipReports() is the sole emitter and runs from service.after() (line 661) BEFORE the deferred-finish flush (line 670); per-item try/catch is present in the drain loop and at both after() call sites. The uuid-keyed map + defer-time uuid pin remain as the defence-in-depth layer and are cleared on every flush (no leak). No new inline skip sink was introduced.

Prior findings status

  • Warning 1 [TESTING] — uuid-keyed map + pinned-uuid flush had no direct regression test → RESOLVED. testHubModule.deferredFinish.test.ts now asserts BOTH properties with real assertions on the recorded gRPC payloads (not tautologies on a mock): (a) two finishes stashed against the SAME instance under different uuids → pendingTestFinishes.size === 2 and ONE flush emits BOTH ({first, second}) — the anti-eviction property; (b) a finish stashed under uuid A with the instance's live uuid rewritten to B before the flush → the SENT event's top-level uuid === 'A' (the pin wins). A third case covers same-uuid re-stash (LOG_REPORT re-entry) not duplicating.

  • Warning 2 [GRACEFUL-DEGRADATION] — queued skips silently dropped if after() never runs → RESOLVED (documented-accepted tradeoff). The queue site (skipReporter.ts:100-104) now documents the crash-drop tradeoff (SIGKILL / OOM / teardown error → after() skipped → queued skips dropped with no send attempted) and why it is accepted (the inline path is the bug being fixed; an aborted worker is left to Test Hub's idle reap regardless). This is the comment option — no process-exit / afterSession safety drain was added (confirmed: no such hook exists in service.ts) — which satisfies the either/or ask.

  • Suggestion 1 [DOCS] — changeset omitted the end-of-run ordering behavior change → RESOLVED. .changeset/pr-194.md now notes skips are reported when the run finishes and "appear grouped at the end of the build rather than in source order."

  • Suggestion 2 [CORRECTNESS] — pin covers the top-level uuid but not the uuid inside eventJson → PARTIAL (acknowledged, harmless). Still present: eventJson (testHubModule.ts:221) is serialized from the instance's live testData map, so an embedded uuid is not pinned; only the top-level uuid field (line 231) carries the defer-time pin. Harmless and correctly so — the binary closes the test_run on the top-level uuid, the queue removes the only interleave that could make the two diverge, and the class field-docs now acknowledge eventJson is live-serialized at send time. No action required.

  • Suggestion 3 [MAINTAINABILITY] — asked for a "retained as defence-in-depth" note → RESOLVED. The +7 production lines in testHubModule.ts are exactly that: a NOTE ON REDUNDANCY block on pendingTestFinishes explaining the map is not the primary fix (the queue-and-drain already prevents the interleave) and is retained deliberately as defence-in-depth for a reporting path with real incident history, degrading a future interleave to a late send rather than a lost TestRunFinished.

Full detail in the review summary delivered in chat.

Generated by Automated SDK PR review.

@harshit-browserstack

Copy link
Copy Markdown
Collaborator

RUN_TESTS

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Inline comments:
In `@packages/browserstack-service/src/cli/skipReporter.ts`:
- Line 82: Update emitSkipReport so TEST/PRE, LOG_REPORT/POST, and TEST/POST are
attempted independently even when an earlier trackEvent call rejects, while
retaining the first error for debug logging. Ensure TEST/POST is still attempted
after an intermediate rejection and preserve the existing event ordering.

After applying the fix, consider running `coderabbit review --agent` for local
review. Visit https://docs.coderabbit.ai/cli.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited), Workspace UI (inherited)

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: bb3e417d-d422-409f-9fcd-b4bdd3e32c57

📥 Commits

Reviewing files that changed from the base of the PR and between 82a51d5 and 7ad0b4c.

📒 Files selected for processing (6)
  • .changeset/pr-194.md
  • packages/browserstack-service/src/cli/modules/testHubModule.ts
  • packages/browserstack-service/src/cli/skipReporter.ts
  • packages/browserstack-service/tests/cli/modules/testHubModule.deferredFinish.test.ts
  • packages/browserstack-service/tests/cli/skipReporter.midRunInterleave.test.ts
  • packages/browserstack-service/tests/cli/skipReporter.test.ts

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.

📜 Review details
⚠️ CI failures not shown inline (8)

GitHub Actions: Ready for Review Label / 0_check-ready.txt: fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

##[group]Run echo "::error::The 'ready-for-review' label is required and is not present on this PR."

GitHub Actions: Ready for Review Label / check-ready: fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

##[group]Run echo "::error::The 'ready-for-review' label is required and is not present on this PR."

GitHub Actions: CI / 0_Build & test (node 22).txt: fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "AMBIGUOUS" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNDEFINED" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNKNOWN" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status passed and name of Feature when single "passed" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mObservability only�[2m > �[22mshould call _update with status "failed" if strict mode is "on" and all tests are pending�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn�[32m 0�[2mms�[22m�[39m
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn - caps present�[32m 0�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mCucumber�[2m > �[22mshould correctly annotate Features, Scenarios, and Steps�[32m 9�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mJasmine�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mMocha�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould track hook failures but not add them to main _failReasons when ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus=false�[32m 10�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ign...

GitHub Actions: CI / Build & test (node 22): fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "AMBIGUOUS" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNDEFINED" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNKNOWN" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status passed and name of Feature when single "passed" Scenario ran�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m after�[2m > �[22mObservability only�[2m > �[22mshould call _update with status "failed" if strict mode is "on" and all tests are pending�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn�[32m 0�[2mms�[22m�[39m
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn - caps present�[32m 0�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mCucumber�[2m > �[22mshould correctly annotate Features, Scenarios, and Steps�[32m 9�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mJasmine�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mMocha�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould track hook failures but not add them to main _failReasons when ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus=false�[32m 10�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ign...

GitHub Actions: CI / 1_Build & test (node 20).txt: fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

ps�[32m 10�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mJasmine�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mMocha�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould track hook failures but not add them to main _failReasons when ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus=false�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus is not set�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterTest with pure test failures�[2m > �[22mshould track pure test failures in both _failReasons and _pureTestFailReasons�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22msession status with ignoreHooksStatus=true�[2m > �[22mshould mark session as passed when only hooks fail and ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22msession status with ignoreHooksStatus=true�[2m > �[22mshould mark session as failed when tests fail even with ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22msession status with ignoreHooksStatus=true�[2m > �[22mshould include hook and test failures in reason when ignoreHooksStatus=false�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22monReload with ignoreHooksStatus=true�[2m > �[22mshould use pure test failures for status when ignoreHooksStatus=true�[32m 2�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22monReload with ignoreHooksStatus=true�[2m > �[22mshould pass ...

GitHub Actions: CI / Build & test (node 20): fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

ps�[32m 10�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mJasmine�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m setAnnotation�[2m > �[22mMocha�[2m > �[22mshould correctly annotate Tests�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould track hook failures but not add them to main _failReasons when ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus=false�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus is not set�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterTest with pure test failures�[2m > �[22mshould track pure test failures in both _failReasons and _pureTestFailReasons�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22msession status with ignoreHooksStatus=true�[2m > �[22mshould mark session as passed when only hooks fail and ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22msession status with ignoreHooksStatus=true�[2m > �[22mshould mark session as failed when tests fail even with ignoreHooksStatus=true�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22msession status with ignoreHooksStatus=true�[2m > �[22mshould include hook and test failures in reason when ignoreHooksStatus=false�[32m 1�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22monReload with ignoreHooksStatus=true�[2m > �[22mshould use pure test failures for status when ignoreHooksStatus=true�[32m 2�[2mms�[22m�[39m
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22monReload with ignoreHooksStatus=true�[2m > �[22mshould pass ...

GitHub Actions: CI / 2_Build & test (node 18.20).txt: fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

nly�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "AMBIGUOUS" Scenario ran
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNDEFINED" Scenario ran
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNKNOWN" Scenario ran
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status passed and name of Feature when single "passed" Scenario ran
    �[32m✓�[39m after�[2m > �[22mObservability only�[2m > �[22mshould call _update with status "failed" if strict mode is "on" and all tests are pending
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn - caps present
    �[32m✓�[39m setAnnotation�[2m > �[22mCucumber�[2m > �[22mshould correctly annotate Features, Scenarios, and Steps
    �[32m✓�[39m setAnnotation�[2m > �[22mJasmine�[2m > �[22mshould correctly annotate Tests
    �[32m✓�[39m setAnnotation�[2m > �[22mMocha�[2m > �[22mshould correctly annotate Tests
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould track hook failures but not add them to main _failReasons when ignoreHooksStatus=true
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus=false
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus is not set
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterTest with pure test failures�[2m > �[22mshould track pure test failures...

GitHub Actions: CI / Build & test (node 18.20): fix(cli): queue skip reports until the drain so a mid-run skip is not lost (SDK-7493)

Conclusion: failure

View job details

nly�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "AMBIGUOUS" Scenario ran
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNDEFINED" Scenario ran
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status failed and name of Feature when single "UNKNOWN" Scenario ran
    �[32m✓�[39m after�[2m > �[22mCucumber only�[2m > �[22mpreferScenarioName�[2m > �[22mdisabled�[2m > �[22mshould call _update /w status passed and name of Feature when single "passed" Scenario ran
    �[32m✓�[39m after�[2m > �[22mObservability only�[2m > �[22mshould call _update with status "failed" if strict mode is "on" and all tests are pending
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn
    �[32m✓�[39m _updateCaps�[2m > �[22mcalls fn - caps present
    �[32m✓�[39m setAnnotation�[2m > �[22mCucumber�[2m > �[22mshould correctly annotate Features, Scenarios, and Steps
    �[32m✓�[39m setAnnotation�[2m > �[22mJasmine�[2m > �[22mshould correctly annotate Tests
    �[32m✓�[39m setAnnotation�[2m > �[22mMocha�[2m > �[22mshould correctly annotate Tests
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould track hook failures but not add them to main _failReasons when ignoreHooksStatus=true
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus=false
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterHook with ignoreHooksStatus=true�[2m > �[22mshould add hook failures to _failReasons when ignoreHooksStatus is not set
    �[32m✓�[39m ignoreHooksStatus feature�[2m > �[22mafterTest with pure test failures�[2m > �[22mshould track pure test failures...
🧰 Additional context used
🪛 markdownlint-cli2 (0.23.2)
.changeset/pr-194.md

[warning] 5-5: First line in a file should be a top-level heading

(MD041, first-line-heading, first-line-h1)

🔇 Additional comments (6)
packages/browserstack-service/src/cli/modules/testHubModule.ts (1)

41-57: LGTM!

Also applies to: 102-102, 137-146, 161-199, 214-217

packages/browserstack-service/tests/cli/modules/testHubModule.deferredFinish.test.ts (1)

152-156: LGTM!

Also applies to: 197-247

packages/browserstack-service/src/cli/skipReporter.ts (1)

35-64: LGTM!

Also applies to: 97-105

packages/browserstack-service/tests/cli/skipReporter.midRunInterleave.test.ts (1)

1-108: LGTM!

packages/browserstack-service/tests/cli/skipReporter.test.ts (1)

8-8: LGTM!

Also applies to: 25-26, 42-42, 50-50, 67-67, 85-85

.changeset/pr-194.md (1)

1-5: LGTM!

Comment thread packages/browserstack-service/src/cli/skipReporter.ts
@pranay-v29

pranay-v29 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

🔴 Blocking findings — fix required

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

Change map (generated deterministically from the diff)

graph LR
  subgraph nwdio_service["wdio-service"]
    npackages_browserstack_service_tests_cli_skipReporter_midRunInterleave_test_ts["skipReporter.midRunInterleave.test.ts<br/>~108 lines"]
    npackages_browserstack_service_src_cli_modules_testHubModule_ts["⚠ testHubModule.ts<br/>~92 lines"]
    npackages_browserstack_service_tests_cli_modules_testHubModule_deferredFinish_test_ts["⚠ testHubModule.deferredFinish.test.ts<br/>~59 lines"]
    npackages_browserstack_service_src_cli_skipReporter_ts["skipReporter.ts<br/>~35 lines"]
    npackages_browserstack_service_tests_cli_skipReporter_test_ts["skipReporter.test.ts<br/>~35 lines"]
    n_changeset_pr_194_md["pr-194.md<br/>~5 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

… (SDK-7493)

Two fixes from CI and review.

1. CI regression (Build & test, all node versions): queuing EVERY skip path broke the
   bail cascade. `service.test.ts` asserts the cascade reports inline from afterTest
   ("expected spy to be called 10 times, but got 2"). Only the un-awaited onTestSkip
   path has the interleave — wdio awaits both reportSuiteSkipped callers (the failed-hook
   cascade in afterHook and the bail cascade in afterTest), so nothing can claim the
   tracked slot underneath them. reportSuiteSkipped now reports immediately via a new
   `{ immediate: true }` option; only the reporter's detached path queues. Restores the
   previous behaviour for the awaited paths, which were never broken.

2. Review finding (CodeRabbit): emitSkipReport awaited each trackEvent in sequence, so a
   rejection from TEST/PRE or LOG_REPORT/POST returned before TEST/POST -- leaving the
   test started-but-never-finished, i.e. the exact "In Progress" outcome this ticket is
   about. Each step is now attempted independently, the first error is retained and
   rethrown for the caller's debug log, and a test asserts all four events fire in order
   despite an intermediate rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not reviewed the current head commit yet — run the SDK PR Review Agent.

It turns green once the latest SDK PR Review Agent run reports GTG on the current head commit. A native reviewer approval is separately required by branch protection before merge.

@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

RUN_TESTS

@pranay-v29

Copy link
Copy Markdown
Collaborator

PR review is done and posted: 🔴 failure — verdict comment

Mode: first-review (no prior verdict comment) — 5 units, 18 regions, all judged, no coverage gaps.

Findings

  1. 🔴 Critical — CONFIRMED · packages/browserstack-service/src/cli/skipReporter.ts:62 (emitSkipReport)
    The last skip drained by drainSkipReports() can lose its own TestRunFinished. The synthetic TEST/POST hits TestHubModule.onAllTestEvents like a real test's, so the pre-existing mocha-defer condition stashes it in pendingTestFinishes. Skips 1..N-1 get flushed by the next skip's INIT_TEST, but the last one has no follower — and service.after() calls flushPendingTestFinishEvent() before drainSkipReports(), so nothing flushes it. That reproduces the exact "stuck In Progress" bug this PR is meant to fix, for the final queued skip. Spans testHubModule.ts + skipReporter.ts + service.ts call order; only the cross-cutting pass could see it.
  2. 🔴 Critical — CONFIRMED · .changeset/pr-194.md:2
    Targets only main (v9), no v8 backport referenced. The interleave bug sits in generic event-dispatch plumbing shared across release lines (architecture card WD-C4), so v8 users likely keep the stuck-"In Progress" behavior after merge.
  3. 🟠 Warning — PLAUSIBLE (ungrounded) · packages/browserstack-service/src/cli/modules/testHubModule.ts:217
    The defer-time uuid pin covers only the top-level uuid; eventJson below is still built from live instance data, so it could carry a stale uuid if the interleave is ever reintroduced. Can't be confirmed from an SDK-only diff — needs a paired Binary PR or an independent reviewer. The author raised this themselves in a self-review thread, but an author's own comment can't clear a finding.

@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

Thanks — went through all three. Findings 1 and 2 are not valid; both rest on a premise I can check directly. Finding 3 stands as open. Detail below so it can be re-judged rather than taken on my word.


1. 🔴 "The last drained skip loses its TestRunFinished" — not valid: the stated call order is inverted

The finding says:

service.after() calls flushPendingTestFinishEvent() before drainSkipReports(), so nothing flushes it

It is the other way round on this head (d13a549), and has been since #178 — that reordering was #178's entire fix:

service.ts:661   await drainSkipReports()
service.ts:670   await testHubModule?.flushPendingTestFinishEvent()

So the sequence for the final queued skip is: drain emits its TEST/POSTonAllTestEvents stashes it in pendingTestFinishes → the flush 9 lines later sends it. The "no follower" case the finding describes is exactly what the drain-then-flush order exists to cover.

Empirically, on real builds from this branch — the last drained skip is terminal in every one:

build shape skip result
cv2s5gqa Test 1 / it.skip Test 2 / Test 3 Test 2 = skipped, in progress: 0
4vropsag describe.skip of two tests both skipped — including the last drained
r3atsy7i trailing it.skip skipped, terminal

If the last drained skip lost its finish, cv2s5gqa would show Test 2 as pending — which is precisely the pre-fix state on build tist6o8g, and the thing the PR is verified against.

There is also a unit test pinning the order: tests/service.afterSkipOrdering.test.ts asserts drainSkipReports runs before flushPendingTestFinishEvent via mock.invocationCallOrder, and it fails if the two are swapped.

2. 🔴 "No v8 backport; v8 users keep the bug" — not valid: v8 cannot hit this bug

The finding assumes the defect sits in plumbing shared across release lines. It does not — it requires the mocha TEST/POST deferral, and v8 has no deferral at all. On origin/v8:

  • src/cli/modules/testHubModule.tspendingTestFinish occurrences: 0
  • src/service.ts — no drainSkipReports(), no flushPendingTestFinishEvent()
  • src/cli/skipReporter.ts — exists, but emits inline and exports no drainSkipReports

No stash means nothing to evict or mis-key: on v8 a skip's TEST/POST is sent immediately rather than parked in a single slot. The bug chain here — stash → evicted/mis-keyed by an interleave → TestRunFinished never sent — has no v8 equivalent. #178 was likewise v9-only for the same reason.

Happy to be corrected if there is a v8 build showing a skipped test stuck "In Progress"; that would mean a different mechanism and would deserve its own ticket rather than a backport of this one.

3. 🟠 eventJson uuid — agreed, leaving open

No disagreement. My evidence is reproducible from build v0rger0skcw162gkuf8hw6obgqbo5r4fiv3ppres (final TEST/POST carried top-level uuid=67421cd8 with the skip's eventJson; TRA closed Test 3 = 67421cd8, so field 5 routes), but per Rule A that is an author's own artifact and should not close the finding. It needs a Binary-side confirmation that test_run closure keys on TestFrameworkEventRequest.uuid. If it turns out to key on a uuid parsed from eventJson, the fix is a one-line stamp of the pinned uuid into the serialized data before send, and I will make it.


Also worth noting for the record: the previous run's CI failure was a real regression of mine (queuing the awaited bail/hook cascades, not just the detached onTestSkip path), caught by service.test.ts and fixed in d13a549. Build & test is green on 18.20/20/22 on the current head.

…evel field (SDK-7493)

The review finding was right and my earlier rebuttal was wrong.

The binary routes a mocha test_run on the uuid parsed OUT of event_json, not on
TestFrameworkEventRequest.uuid:

  packages/@browserstack/observability/languages/webdriverio/index.js:83
      const event = JSON.parse(eventJson)
  .../webdriverio/index.js:111
      testFrameworkModule.onTestRunFinished(context, event, request)
  .../webdriverio/test-frameworks/mocha/index.js:159
      uuid: event.test_uuid

So pinning only the top-level field left the field that actually decides closure still
read from the instance's live data at send time. A stale test_uuid there closes the wrong
run and leaves the deferred one open — the exact failure the pin exists to prevent.

sendTestFrameworkEvent now applies the pinned uuid to a copy of the serialized data as
well, leaving the instance untouched. Test asserts both the top-level field and
event_json.test_uuid carry the defer-time uuid after the instance uuid is rewritten.

My earlier evidence did not discriminate: in that build the top-level uuid and
event_json.test_uuid were both read from the same instance at send time, so they agreed
and the experiment could not tell which one routed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔴 SDK PR Review gate is red. Pending:

  • The SDK PR Review Agent has not reviewed the current head commit yet — run the SDK PR Review Agent.

It turns green once the latest SDK PR Review Agent run reports GTG on the current head commit. A native reviewer approval is separately required by branch protection before merge.

@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

Correcting myself: finding 3 was valid and my rebuttal was wrong. Fixed in a3956a7.

I claimed the binary routes test_run closure on the top-level TestFrameworkEventRequest.uuid. It does not — for the wdio path it routes on the uuid parsed out of event_json:

packages/@browserstack/observability/languages/webdriverio/index.js:83
    const event = JSON.parse(eventJson);
.../webdriverio/index.js:111
    eventAction = () => testFrameworkModule.onTestRunFinished(context, event, request);
.../webdriverio/test-frameworks/mocha/index.js:159
    uuid: event.test_uuid,

So the pin was covering the field that does not decide closure, and leaving the one that does still read from the instance's live data at send time. Exactly the gap the finding described.

Why my evidence was bad. I cited build v0rger0sk… as proof. It could never have discriminated: in that run the top-level uuid and event_json.test_uuid were both serialized from the same instance at send time, so they agreed. The experiment was consistent with either routing rule, and I read it as confirming one. That is my error, not a gap in the finding — and it is exactly the failure mode Rule A exists to catch, so the rule earned its keep here.

Fix: sendTestFrameworkEvent now applies the pinned uuid to a copy of the serialized data as well, leaving the instance untouched:

const eventData = Object.fromEntries(testData)
if (stateOverride?.uuid) {
    eventData[TestFrameworkConstants.KEY_TEST_UUID] = stateOverride.uuid
}

New test pins the uuid INSIDE event_json too — the binary routes on that, not the top-level field rewrites the instance uuid after the defer and asserts both the top-level field and event_json.test_uuid carry the defer-time uuid.

One consequence worth stating: this makes the pin load-bearing rather than defence-in-depth. It is no longer only a guard against a reintroduced interleave — it is what keeps a deferred finish pointed at its own test_run. I have kept the "NOTE ON REDUNDANCY" wording on the map, which is still genuinely redundant with the queue, but the uuid pin is not.

Related suites 25/25, bail cascade 8/8, tsc and eslint clean.

@AakashHotchandani

Copy link
Copy Markdown
Collaborator Author

RUN_TESTS

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.

3 participants