Skip to content

Commit 473feef

Browse files
committed
Test case/suite rerun stop feature
1 parent 11a0536 commit 473feef

File tree

6 files changed

+204
-19
lines changed

6 files changed

+204
-19
lines changed

packages/app/src/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export class WebdriverIODevtoolsApplication extends Element {
7070
this.requestUpdate()
7171
}
7272

73-
#clearExecutionData() {
74-
this.dataManager.clearExecutionData()
73+
#clearExecutionData({ detail }: { detail?: { uid?: string } }) {
74+
this.dataManager.clearExecutionData(detail?.uid)
7575
}
7676

7777
#mainContent() {

packages/app/src/components/sidebar/explorer.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
125125

126126
// Clear execution data before triggering rerun
127127
this.dispatchEvent(
128-
new CustomEvent('clear-execution-data', { bubbles: true, composed: true })
128+
new CustomEvent('clear-execution-data', {
129+
detail: { uid: detail.uid },
130+
bubbles: true,
131+
composed: true
132+
})
129133
)
130134

131135
const payload = {
@@ -140,8 +144,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
140144

141145
async #handleTestStop(event: Event) {
142146
event.stopPropagation()
143-
const detail = (event as CustomEvent<TestRunDetail>).detail
144-
await this.#postToBackend('/api/tests/stop', { ...detail })
147+
await this.#postToBackend('/api/tests/stop', {})
145148
}
146149

147150
async #postToBackend(path: string, body: Record<string, unknown>) {
@@ -199,9 +202,13 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
199202
return
200203
}
201204

202-
// Clear execution data before triggering rerun
205+
// Clear execution data and mark all tests as running
203206
this.dispatchEvent(
204-
new CustomEvent('clear-execution-data', { bubbles: true, composed: true })
207+
new CustomEvent('clear-execution-data', {
208+
detail: { uid: '*' },
209+
bubbles: true,
210+
composed: true
211+
})
205212
)
206213

207214
void this.#postToBackend('/api/tests/run', {

packages/app/src/controller/DataManager.ts

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ export const suiteContext = createContext<Record<string, any>[]>(
4242

4343
const hasConnection = createContext<boolean>(Symbol('hasConnection'))
4444

45-
interface SocketMessage<T extends keyof TraceLog = keyof TraceLog> {
45+
interface SocketMessage<T extends keyof TraceLog | 'testStopped' = keyof TraceLog | 'testStopped'> {
4646
scope: T
47-
data: TraceLog[T]
47+
data: T extends keyof TraceLog ? TraceLog[T] : unknown
4848
}
4949

5050
export class DataManagerController implements ReactiveController {
@@ -103,17 +103,113 @@ export class DataManagerController implements ReactiveController {
103103
}
104104

105105
// Public method to clear execution data when rerun is triggered
106-
clearExecutionData() {
107-
this.mutationsContextProvider.setValue([])
108-
this.commandsContextProvider.setValue([])
109-
this.logsContextProvider.setValue([])
110-
this.consoleLogsContextProvider.setValue([])
106+
clearExecutionData(uid?: string) {
107+
this.#resetExecutionData()
108+
if (uid) {
109+
this.#markTestAsRunning(uid)
110+
}
111+
}
112+
113+
// Private method to mark a test/suite as running immediately for UI feedback
114+
#markTestAsRunning(uid: string) {
115+
const suites = this.suitesContextProvider.value || []
116+
117+
// If uid is '*', mark ALL tests/suites as running
118+
if (uid === '*') {
119+
const updatedSuites = suites.map((chunk) => {
120+
const updatedChunk: Record<string, SuiteStatsFragment> = {}
121+
Object.entries(chunk as Record<string, SuiteStatsFragment>).forEach(
122+
([suiteUid, suite]) => {
123+
if (!suite) {
124+
updatedChunk[suiteUid] = suite
125+
return
126+
}
127+
128+
const markAllAsRunning = (s: SuiteStatsFragment): SuiteStatsFragment => {
129+
return {
130+
...s,
131+
start: new Date(),
132+
end: undefined,
133+
tests: s.tests?.map((test) => ({
134+
...test,
135+
start: new Date(),
136+
end: undefined
137+
})) || [],
138+
suites: s.suites?.map(markAllAsRunning) || []
139+
}
140+
}
141+
142+
updatedChunk[suiteUid] = markAllAsRunning(suite)
143+
}
144+
)
145+
return updatedChunk
146+
})
147+
this.suitesContextProvider.setValue(updatedSuites)
148+
this.#host.requestUpdate()
149+
return
150+
}
151+
152+
// Otherwise, mark specific test/suite as running
153+
const updatedSuites = suites.map((chunk) => {
154+
const updatedChunk: Record<string, SuiteStatsFragment> = {}
155+
Object.entries(chunk as Record<string, SuiteStatsFragment>).forEach(
156+
([suiteUid, suite]) => {
157+
if (!suite) {
158+
updatedChunk[suiteUid] = suite
159+
return
160+
}
161+
162+
// Recursive helper to mark tests/suites as running
163+
const markAsRunning = (s: SuiteStatsFragment): SuiteStatsFragment => {
164+
// If this is the target suite/test, mark it as running
165+
if (s.uid === uid) {
166+
return {
167+
...s,
168+
start: new Date(),
169+
end: undefined, // Clear end to mark as running
170+
tests: s.tests?.map((test) => ({
171+
...test,
172+
start: new Date(),
173+
end: undefined
174+
})) || [],
175+
suites: s.suites?.map(markAsRunning) || []
176+
}
177+
}
178+
179+
// Check if any child test matches
180+
const updatedTests = s.tests?.map((test) => {
181+
if (test.uid === uid) {
182+
return {
183+
...test,
184+
start: new Date(),
185+
end: undefined
186+
}
187+
}
188+
return test
189+
})
190+
191+
// Recursively check nested suites
192+
const updatedNestedSuites = s.suites?.map(markAsRunning)
193+
194+
return {
195+
...s,
196+
tests: updatedTests || [],
197+
suites: updatedNestedSuites || []
198+
}
199+
}
200+
201+
updatedChunk[suiteUid] = markAsRunning(suite)
202+
}
203+
)
204+
return updatedChunk
205+
})
206+
207+
this.suitesContextProvider.setValue(updatedSuites)
111208
this.#host.requestUpdate()
112209
}
113210

114211
hostConnected() {
115212
const wsUrl = `ws://${window.location.host}/client`
116-
console.log(`Connecting to ${wsUrl}`)
117213
const ws = (this.#ws = new WebSocket(wsUrl))
118214

119215
ws.addEventListener('open', () => {
@@ -150,6 +246,13 @@ export class DataManagerController implements ReactiveController {
150246
return
151247
}
152248

249+
// Handle test stopped event
250+
if (scope === 'testStopped') {
251+
this.#handleTestStopped()
252+
this.#host.requestUpdate()
253+
return
254+
}
255+
153256
// Check for new run BEFORE processing suites data
154257
if (scope === 'suites') {
155258
const shouldReset = this.#shouldResetForNewRun(data)
@@ -227,6 +330,55 @@ export class DataManagerController implements ReactiveController {
227330
this.#host.requestUpdate()
228331
}
229332

333+
#handleTestStopped() {
334+
// Mark all running tests as failed when test execution is stopped
335+
const suites = this.suitesContextProvider.value || []
336+
const updatedSuites = suites.map((chunk) => {
337+
const updatedChunk: Record<string, SuiteStatsFragment> = {}
338+
Object.entries(chunk as Record<string, SuiteStatsFragment>).forEach(
339+
([uid, suite]) => {
340+
if (!suite) {
341+
updatedChunk[uid] = suite
342+
return
343+
}
344+
345+
// Recursive helper to update tests and nested suites
346+
const updateSuite = (s: SuiteStatsFragment): SuiteStatsFragment => {
347+
const updatedTests = s.tests?.map((test): TestStatsFragment => {
348+
// If test is running (no end time), mark it as failed
349+
if (test && !test.end) {
350+
return {
351+
...test,
352+
end: new Date(),
353+
state: 'failed' as 'failed',
354+
error: {
355+
message: 'Test execution stopped',
356+
name: 'TestStoppedError'
357+
}
358+
}
359+
}
360+
return test
361+
})
362+
363+
// Recursively update nested suites (for Cucumber scenarios)
364+
const updatedNestedSuites = s.suites?.map(updateSuite)
365+
366+
return {
367+
...s,
368+
tests: updatedTests || [],
369+
suites: updatedNestedSuites || []
370+
}
371+
}
372+
373+
updatedChunk[uid] = updateSuite(suite)
374+
}
375+
)
376+
return updatedChunk
377+
})
378+
379+
this.suitesContextProvider.setValue(updatedSuites)
380+
}
381+
230382
#handleMutationsUpdate(data: TraceMutation[]) {
231383
this.mutationsContextProvider.setValue([
232384
...(this.mutationsContextProvider.value || []),

packages/app/src/vite-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,5 @@ interface GlobalEventHandlersEventMap {
2323
'app-logs': CustomEvent<string>
2424
'load-trace': CustomEvent<TraceLog>
2525
'show-command': CustomEvent<CommandEventProps>
26-
'clear-execution-data': CustomEvent<void>
26+
'clear-execution-data': CustomEvent<{ uid?: string }>
2727
}

packages/backend/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ interface DevtoolsBackendOptions {
2121
const log = logger('@wdio/devtools-backend')
2222
const clients = new Set<WebSocket>()
2323

24+
export function broadcastToClients(message: string) {
25+
clients.forEach((client) => {
26+
if (client.readyState === WebSocket.OPEN) {
27+
client.send(message)
28+
}
29+
})
30+
}
31+
2432
export async function start(opts: DevtoolsBackendOptions = {}) {
2533
const host = opts.hostname || 'localhost'
2634
const port = opts.port || (await getPort({ port: DEFAULT_PORT }))
@@ -55,6 +63,10 @@ export async function start(opts: DevtoolsBackendOptions = {}) {
5563

5664
server.post('/api/tests/stop', async (_request, reply) => {
5765
testRunner.stop()
66+
broadcastToClients(JSON.stringify({
67+
scope: 'testStopped',
68+
data: { stopped: true, timestamp: Date.now() }
69+
}))
5870
reply.send({ ok: true })
5971
})
6072

packages/backend/src/runner.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'node:fs'
33
import path from 'node:path'
44
import url from 'node:url'
55
import { createRequire } from 'node:module'
6+
import kill from 'tree-kill'
67

78
const require = createRequire(import.meta.url)
89
const wdioBin = resolveWdioBin()
@@ -151,7 +152,8 @@ class TestRunner {
151152
const child = spawn(process.execPath, args, {
152153
cwd: this.#baseDir,
153154
env: childEnv,
154-
stdio: 'inherit'
155+
stdio: 'inherit',
156+
detached: false
155157
})
156158

157159
this.#child = child
@@ -175,10 +177,22 @@ class TestRunner {
175177
}
176178

177179
stop() {
178-
if (!this.#child) {
180+
if (!this.#child || !this.#child.pid) {
179181
return
180182
}
181-
this.#child.kill('SIGINT')
183+
184+
const pid = this.#child.pid
185+
186+
// Kill the entire process tree
187+
kill(pid, 'SIGTERM', (err) => {
188+
if (err) {
189+
console.error('Error stopping test run:', err)
190+
// Try force kill if graceful termination fails
191+
kill(pid, 'SIGKILL')
192+
}
193+
})
194+
195+
// Clean up immediately
182196
this.#child = undefined
183197
this.#lastPayload = undefined
184198
this.#baseDir = process.cwd()

0 commit comments

Comments
 (0)