Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/rerun-early-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@wdio/devtools-backend': patch
'@wdio/devtools-app': patch
---

A rerun whose process exited before it reported anything, such as a config preflight refusing to start or a missing driver, left the dashboard showing "Running" with only a Stop button, so it could not be rerun. The backend now tells the dashboard when that happens. The tests the run never reached are marked failed with the exit code or signal as their error, and a suite or Run All rerun, which has no test left to mark, shows the reason on its suite and in the Errors tab.
29 changes: 28 additions & 1 deletion packages/app/src/components/workbench/errors/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,33 @@ function collectFailedTests(
return [...byUid.values()]
}

/** Suites carrying their own error: a stopped rerun that cleared their tests. */
function suiteErrors(
suites: Record<string, SuiteStatsFragment>[] | undefined
): CollectedError[] {
const byUid = new Map<string, CollectedError>()
const visit = (suite: SuiteStatsFragment) => {
const read = readError(suite.error)
if (read) {
byUid.set(suite.uid, {
title: suite.fullTitle || suite.title || suite.uid,
message: read.message,
stack: read.stack,
callSource: suite.callSource
})
}
for (const child of suite.suites ?? []) {
visit(child)
}
}
for (const map of suites ?? []) {
for (const suite of Object.values(map)) {
visit(suite)
}
}
return [...byUid.values()]
}

function commandErrors(commands: CommandLog[] | undefined): CollectedError[] {
return (commands ?? [])
.flatMap((command) => {
Expand Down Expand Up @@ -293,5 +320,5 @@ export function collectErrors(
]
})

return [...fromCommands, ...fromTests]
return [...fromCommands, ...fromTests, ...suiteErrors(suites)]
}
11 changes: 7 additions & 4 deletions packages/app/src/controller/DataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import type {
NetworkRequest,
TraceLog,
PreservedAttempt,
TracePlayerData
TracePlayerData,
WsPayloadFor
} from '@wdio/devtools-shared'

import {
Expand Down Expand Up @@ -285,7 +286,7 @@ export class DataManagerController implements ReactiveController {
// dispatch should be skipped. Caller is responsible for requestUpdate().
#handleControlScope(scope: string, data: unknown): boolean {
if (scope === WS_SCOPE.testStopped) {
this.#handleTestStopped()
this.#handleTestStopped(data as WsPayloadFor<'testStopped'> | undefined)
return true
}
if (scope === 'screencast') {
Expand Down Expand Up @@ -396,11 +397,13 @@ export class DataManagerController implements ReactiveController {
this.#host.requestUpdate()
}

#handleTestStopped() {
#handleTestStopped(data?: WsPayloadFor<'testStopped'>) {
this.#activeRerunTestUid = undefined
rerunState.activeRerunSuiteUid = undefined
const suites = this.suitesContextProvider.value || []
this.suitesContextProvider.setValue(markRunningAsStopped(suites))
this.suitesContextProvider.setValue(
markRunningAsStopped(suites, data?.reason)
)
}

#handleMutationsUpdate(data: TraceMutation[]) {
Expand Down
129 changes: 86 additions & 43 deletions packages/app/src/controller/mark-running.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function markAllRunning(suites: SuiteChunks): SuiteChunks {
state: 'running',
start: new Date(),
end: undefined,
error: undefined,
tests: [] as TestStatsFragment[],
suites: s.suites?.map(markAllAsRunning) || []
})
Expand Down Expand Up @@ -67,6 +68,7 @@ function markSuiteTreeAsRunning(
state: 'running',
start: runStart,
end: undefined,
error: undefined,
tests: [] as TestStatsFragment[],
suites:
suiteNode.suites?.map((s) => markSuiteTreeAsRunning(s, runStart)) || []
Expand Down Expand Up @@ -110,6 +112,7 @@ function markSuiteWithUid(
...(matched
? {
state: 'running' as const,
error: undefined,
// Preserve parent's start/end if already running — subsequent
// child-scenario marks would otherwise reset the feature's
// original run timestamp.
Expand Down Expand Up @@ -150,66 +153,106 @@ export function markSpecificRunning(
})
}

type StopError = NonNullable<TestStatsFragment['error']>

/** A test the run never settled is cancelled by the stop; one that already
* reported an outcome keeps it. */
const stopTest = (test: TestStatsFragment): TestStatsFragment =>
test && isInFlight(test)
? {
...test,
end: new Date(),
state: TEST_STATE.FAILED,
error: { message: 'Test execution stopped', name: 'TestStoppedError' }
}
: test
const stopTest =
(error: StopError) =>
(test: TestStatsFragment): TestStatsFragment =>
test && isInFlight(test)
? { ...test, end: new Date(), state: TEST_STATE.FAILED, error }
: test

/**
* Mark every test the run never settled as failed. Used when the user manually
* stops the run from the dashboard — without this, suites with
* `state: 'running'` would keep showing their spinner indefinitely. A test that
* already reported an outcome keeps it: a skipped test carries no `end` stamp,
* so keying the flip off `end` used to relabel every skipped test as failed.
*
* The suite's state is derived from its updated children: if any child failed
* (or the suite itself was 'running' with no live children left), the suite
* ends up failed — a stopped run is not a passing one. Otherwise the existing
* state is preserved.
* state is preserved. `failedByStopAlone` collects the suites whose failure
* has no cause but the stop: no failed test, and only such suites failed below.
*/
export function markRunningAsStopped(suites: SuiteChunks): SuiteChunks {
const updateSuite = (s: SuiteStatsFragment): SuiteStatsFragment => {
const updatedTests = s.tests?.map(stopTest)
const updatedNestedSuites = s.suites?.map(updateSuite)
function stopSuite(
s: SuiteStatsFragment,
error: StopError,
failedByStopAlone: WeakSet<SuiteStatsFragment>
): SuiteStatsFragment {
const updatedTests = s.tests?.map(stopTest(error)) || []
const updatedNestedSuites =
s.suites?.map((child) => stopSuite(child, error, failedByStopAlone)) || []

const childStates = tallyOutcomes([
...(updatedTests || []),
...(updatedNestedSuites || [])
])
const stillRunning = hasInFlight(childStates)
const derivedState: SuiteStatsFragment['state'] = stillRunning
? s.state
: hasFailure(childStates)
const childStates = tallyOutcomes([...updatedTests, ...updatedNestedSuites])
const stillRunning = hasInFlight(childStates)
const derivedState: SuiteStatsFragment['state'] = stillRunning
? s.state
: hasFailure(childStates)
? TEST_STATE.FAILED
: s.state === TEST_STATE.RUNNING
? TEST_STATE.FAILED
: s.state === TEST_STATE.RUNNING
? TEST_STATE.FAILED
: s.state
: s.state

return {
...s,
state: derivedState,
...(!stillRunning && !s.end ? { end: new Date() } : {}),
tests: updatedTests || [],
suites: updatedNestedSuites || []
}
const stopped: SuiteStatsFragment = {
...s,
state: derivedState,
...(!stillRunning && !s.end ? { end: new Date() } : {}),
tests: updatedTests,
suites: updatedNestedSuites
}
if (
s.state === TEST_STATE.RUNNING &&
derivedState === TEST_STATE.FAILED &&
!updatedTests.some((test) => test.state === TEST_STATE.FAILED) &&
updatedNestedSuites.every(
(child) =>
child.state !== TEST_STATE.FAILED || failedByStopAlone.has(child)
)
) {
failedByStopAlone.add(stopped)
}
return stopped
}

/** A suite rerun leaves no test to show why, so the topmost suite the stop alone failed carries it. */
function carryReason(
s: SuiteStatsFragment,
error: StopError,
failedByStopAlone: WeakSet<SuiteStatsFragment>
): SuiteStatsFragment {
if (failedByStopAlone.has(s)) {
return { ...s, error }
}
return {
...s,
suites: s.suites?.map((child) =>
carryReason(child, error, failedByStopAlone)
)
}
}

/**
* Mark every test the run never settled as failed. Used when the run is
* stopped from the dashboard or exits before reporting — without this, suites
* with `state: 'running'` would keep showing their spinner indefinitely. A test
* that already reported an outcome keeps it: a skipped test carries no `end`
* stamp, so keying the flip off `end` used to relabel every skipped test as
* failed.
*/
export function markRunningAsStopped(
suites: SuiteChunks,
reason = 'Test execution stopped'
): SuiteChunks {
const error: StopError = { message: reason, name: 'TestStoppedError' }
const failedByStopAlone = new WeakSet<SuiteStatsFragment>()
return suites.map((chunk) => {
const updatedChunk: Record<string, SuiteStatsFragment> = {}
Object.entries(chunk as Record<string, SuiteStatsFragment>).forEach(
([uid, suite]) => {
if (!suite) {
updatedChunk[uid] = suite
return
}
updatedChunk[uid] = updateSuite(suite)
updatedChunk[uid] = suite
? carryReason(
stopSuite(suite, error, failedByStopAlone),
error,
failedByStopAlone
)
: suite
}
)
return updatedChunk
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/controller/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ export type SuiteStatsFragment = Omit<
featureLine?: number
type?: string
file?: string
/** Set only by a stop that failed the suite with no test left to say why. */
error?: TestStatsFragment['error']
}
56 changes: 56 additions & 0 deletions packages/app/tests/data-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,62 @@ describe('DataManagerController', () => {
expect(first.tests?.[0].state).toBe('failed')
expect(first.tests?.[0].end).toBeDefined()
expect(first.state).toBe('failed')
expect(first.tests?.[0].error?.message).toBe('Test execution stopped')
})

it('ends a run that exited before reporting, naming why', async () => {
const { manager, deliver } = await boot()
deliver(
'suites',
suitesFrame(
suite('login-suite', {
state: 'running',
tests: [test('t-1', { state: 'pending', end: undefined })]
})
)
)

const reason = 'Run exited before reporting (exit code 1)'
deliver(WS_SCOPE.testStopped, {
stopped: true,
timestamp: RUN_START,
reason
})

const [first] = publishedSuites(manager)
expect(first.tests?.[0].state).toBe('failed')
expect(first.tests?.[0].error?.message).toBe(reason)
expect(first.state).toBe('failed')
})

it('keeps the reason when a suite rerun exits before reporting', async () => {
const { manager, deliver } = await boot()
deliver(
'suites',
suitesFrame(
suite('login-suite', {
state: 'passed',
tests: [test('t-1', { state: 'passed' })]
})
)
)
deliver(WS_SCOPE.clearExecutionData, {
uid: 'login-suite',
entryType: 'suite',
runStart: true
})

const reason = 'Run exited before reporting (exit code 1)'
deliver(WS_SCOPE.testStopped, {
stopped: true,
timestamp: RUN_START,
reason
})

const [first] = publishedSuites(manager)
expect(first.tests).toEqual([])
expect(first.state).toBe('failed')
expect(first.error?.message).toBe(reason)
})
})

Expand Down
24 changes: 24 additions & 0 deletions packages/app/tests/errors-collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,30 @@ describe('collectErrors', () => {
expect(errors[0].timestamp).toBeUndefined()
})

it('collects a suite carrying its own error after a stopped rerun', () => {
const errors = collectErrors(
[],
suiteMap({
uid: 'login',
fullTitle: 'login suite',
state: 'failed',
tests: [],
error: {
name: 'TestStoppedError',
message: 'Run exited before reporting (exit code 1)'
}
})
)
expect(errors).toEqual([
{
title: 'login suite',
message: 'Run exited before reporting (exit code 1)',
stack: undefined,
callSource: undefined
}
])
})

it('reads the first entry of errors[] when error is absent', () => {
const [error] = collectErrors(
[],
Expand Down
Loading
Loading