Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pr-194.md
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.
104 changes: 73 additions & 31 deletions packages/browserstack-service/src/cli/modules/testHubModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

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.


/**
* Create a new TestHubModule
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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()

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.

// 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,
Expand Down
117 changes: 105 additions & 12 deletions packages/browserstack-service/src/cli/skipReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,61 @@ const reportedSkips = new Set<string>()
// tracker's single mutable per-worker instance — serialize every report through one chain
let reportChain: Promise<void> = Promise.resolve()

/**
* SDK-7493: skip reports are QUEUED here and only emitted from drainSkipReports(), never at
* the moment onTestSkip fires.
*
* wdio does not await onTestSkip, so emitting inline let a skip's events interleave with a
* live test's. Both share 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 the two tests' TEST/POSTs
* collapsed onto one uuid — one TestRunFinished was lost (test stuck "In Progress" until the
* ~60-min reap) and the survivor carried the wrong result. Deferring to the drain removes the
* interleave entirely: no test is in flight there, so each skip gets its own instance and uuid.
*/
interface QueuedSkip {
framework: TestFramework
test: Frameworks.Test
result: Frameworks.TestResult
suiteTitle?: string
}
const queuedSkips: QueuedSkip[] = []

/**
* Emit one skip's full event sequence, in order.
*
* Every step is attempted even if an earlier one rejects. TEST/POST is what ultimately
* produces the TestRunFinished, and abandoning the sequence on an earlier failure is the
* exact outcome this ticket exists to prevent: a test that is started and never finished
* sits "In Progress" until Test Hub's ~60-min idle reap. A partial report — worse ordering,
* a missing log payload — is strictly better than an unterminated test run.
*
* The first error is retained and rethrown so the caller still logs a real failure rather
* than silently reporting success.
*/
async function emitSkipReport({ framework, test, result, suiteTitle }: QueuedSkip): Promise<void> {
// LOG_REPORT/POST is what loads the result into the instance (loadTestResult is
// gated on it, not on TEST/POST) — same sequence afterTest uses
const steps: Array<[State, State, Record<string, unknown>]> = [
[TestFrameworkState.INIT_TEST, HookState.PRE, { test }],
[TestFrameworkState.TEST, HookState.PRE, { test, suiteTitle }],
[TestFrameworkState.LOG_REPORT, HookState.POST, { test, result }],
[TestFrameworkState.TEST, HookState.POST, { test, result, suiteTitle }],
]

let firstError: unknown
for (const [state, hook, args] of steps) {
try {
await framework.trackEvent(state, hook, args)
} catch (err: unknown) {
firstError ??= err
}
}
if (firstError !== undefined) {
throw firstError
}
}

export function markTestStarted(identifier: string) {
startedTests.add(identifier)
}
Expand All @@ -42,32 +97,70 @@ export function markTestStarted(identifier: string) {
// so the chain completes while the session is still open. (Hook-skip cascades go via
// reportSuiteSkipped inside afterHook, which is already awaited, so they were unaffected.)
export function drainSkipReports(): Promise<void> {
// Emit everything queued so far, strictly one at a time. Drains until empty rather than
// snapshotting: emitting a skip can enqueue nothing today, but draining a growing queue is
// the safe shape. Runs from service.after(), where no test is in flight.
reportChain = reportChain.then(async () => {
while (queuedSkips.length > 0) {
const queued = queuedSkips.shift()!
try {
await emitSkipReport(queued)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err: unknown) {
BStackLogger.debug(`Failed reporting skipped test '${queued.test.title}': ${err}`)
}
}
})
return reportChain
}

export function reportSkippedTest(framework: TestFramework, identifier: string, test: Frameworks.Test, suiteTitle?: string): Promise<void> {
export function reportSkippedTest(
framework: TestFramework,
identifier: string,
test: Frameworks.Test,
suiteTitle?: string,
options?: { immediate?: boolean }
): Promise<void> {
if (startedTests.has(identifier) || reportedSkips.has(identifier)) {
return reportChain
}
reportedSkips.add(identifier)
const result = { passed: false, skipped: true } as Frameworks.TestResult
reportChain = reportChain.then(async () => {
// LOG_REPORT/POST is what loads the result into the instance (loadTestResult is
// gated on it, not on TEST/POST) — same sequence afterTest uses
await framework.trackEvent(TestFrameworkState.INIT_TEST, HookState.PRE, { test })
await framework.trackEvent(TestFrameworkState.TEST, HookState.PRE, { test, suiteTitle })
await framework.trackEvent(TestFrameworkState.LOG_REPORT, HookState.POST, { test, result })
await framework.trackEvent(TestFrameworkState.TEST, HookState.POST, { test, result, suiteTitle })
}).catch((err: unknown) => {
BStackLogger.debug(`Failed reporting skipped test '${identifier}': ${err}`)
})
const queued: QueuedSkip = { framework, test, result, suiteTitle }

// SDK-7493: only the DETACHED caller needs deferring. `immediate` is for callers wdio
// awaits — the hook cascade (afterHook) and the bail cascade (afterTest). Those never had
// the interleave, because wdio holds the lifecycle open until they resolve, so nothing else
// can claim the tracked slot underneath them. Deferring those too would be a behaviour
// change for no benefit: their skips would move to end-of-run and their reports would no
// longer be part of the hook/test they belong to.
if (options?.immediate) {
reportChain = reportChain.then(() => emitSkipReport(queued)).catch((err: unknown) => {
BStackLogger.debug(`Failed reporting skipped test '${identifier}': ${err}`)
})
return reportChain
}

// The un-awaited `onTestSkip` path: queue it — see the QueuedSkip docs above. Emitting here
// would interleave this skip's events with whatever test is currently running.
//
// 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.
queuedSkips.push(queued)
return reportChain
}

/**
* Port of the legacy insights-handler skip propagation: when a BEFORE_ALL/BEFORE_EACH/
* AFTER_EACH hook fails (or skips), mocha silently drops the remaining tests in the
* suite — report each state-undefined test as skipped, recursing into nested describes.
*
* Reports IMMEDIATELY (SDK-7493): every caller of this — the failed-hook cascade in
* `afterHook` and the bail cascade in `afterTest` — is awaited by wdio, so these reports
* cannot interleave with a live test the way the un-awaited `onTestSkip` path could. They
* belong to the hook/test being reported, so they must not slide to end-of-run.
*/
export async function reportSuiteSkipped(framework: TestFramework, suite: { tests?: unknown[], suites?: unknown[] }): Promise<void> {
for (const t of (suite.tests || []) as MochaRuntimeTest[]) {
Expand All @@ -86,7 +179,7 @@ export async function reportSuiteSkipped(framework: TestFramework, suite: { test
file: t.file,
ctx: { test: { parent: t.parent } }
} as unknown as Frameworks.Test
await reportSkippedTest(framework, identifier, synthetic, parentTitle)
await reportSkippedTest(framework, identifier, synthetic, parentTitle, { immediate: true })
}
for (const sub of (suite.suites || []) as { tests?: unknown[], suites?: unknown[] }[]) {
await reportSuiteSkipped(framework, sub)
Expand Down
Loading
Loading