Skip to content

Commit a9fe90b

Browse files
committed
Test re-run - now preserve the previous test results for easy access of other test case/suite
1 parent c550966 commit a9fe90b

8 files changed

Lines changed: 338 additions & 57 deletions

File tree

packages/app/src/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class WebdriverIODevtoolsApplication extends Element {
5252
connectedCallback(): void {
5353
super.connectedCallback()
5454
window.addEventListener('load-trace', this.#loadTrace.bind(this))
55+
this.addEventListener('clear-execution-data', this.#clearExecutionData.bind(this))
5556
}
5657

5758
render() {
@@ -66,6 +67,10 @@ export class WebdriverIODevtoolsApplication extends Element {
6667
this.requestUpdate()
6768
}
6869

70+
#clearExecutionData() {
71+
this.dataManager.clearExecutionData()
72+
}
73+
6974
#mainContent() {
7075
if (!this.dataManager.hasConnection) {
7176
return html`<wdio-devtools-start></wdio-devtools-start>`

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
120120
return
121121
}
122122

123+
// Clear execution data before triggering rerun
124+
this.dispatchEvent(new CustomEvent('clear-execution-data', { bubbles: true, composed: true }))
125+
123126
await this.#postToBackend('/api/tests/run', {
124127
...detail,
125128
runAll: detail.uid === '*',
@@ -190,6 +193,9 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
190193
return
191194
}
192195

196+
// Clear execution data before triggering rerun
197+
this.dispatchEvent(new CustomEvent('clear-execution-data', { bubbles: true, composed: true }))
198+
193199
void this.#postToBackend('/api/tests/run', {
194200
uid: '*',
195201
entryType: 'suite',

packages/app/src/controller/DataManager.ts

Lines changed: 236 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@ import type {
55
CommandLog,
66
TraceLog
77
} from '@wdio/devtools-service/types'
8+
import type { SuiteStats, TestStats } from '@wdio/reporter'
89

910
const CACHE_ID = 'wdio-trace-cache'
1011

12+
type TestStatsFragment = Omit<Partial<TestStats>, 'uid'> & { uid: string }
13+
14+
type SuiteStatsFragment = Omit<
15+
Partial<SuiteStats>,
16+
'uid' | 'tests' | 'suites'
17+
> & {
18+
uid: string
19+
tests?: TestStatsFragment[]
20+
suites?: SuiteStatsFragment[]
21+
}
22+
1123
export const mutationContext = createContext<TraceMutation[]>(
1224
Symbol('mutationContext')
1325
)
@@ -38,6 +50,7 @@ interface SocketMessage<T extends keyof TraceLog = keyof TraceLog> {
3850
export class DataManagerController implements ReactiveController {
3951
#ws?: WebSocket
4052
#host: ReactiveControllerHost & HTMLElement
53+
#lastSeenRunTimestamp = 0
4154

4255
mutationsContextProvider: ContextProvider<typeof mutationContext>
4356
logsContextProvider: ContextProvider<typeof logContext>
@@ -46,7 +59,6 @@ export class DataManagerController implements ReactiveController {
4659
commandsContextProvider: ContextProvider<typeof commandContext>
4760
sourcesContextProvider: ContextProvider<typeof sourceContext>
4861
suitesContextProvider: ContextProvider<typeof suiteContext>
49-
5062
hasConnectionProvider: ContextProvider<typeof hasConnection>
5163

5264
constructor(host: ReactiveControllerHost & HTMLElement) {
@@ -90,30 +102,26 @@ export class DataManagerController implements ReactiveController {
90102
return this.metadataContextProvider.value?.type
91103
}
92104

93-
/**
94-
* connect to backend to receive data
95-
*/
105+
// 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([])
111+
this.#host.requestUpdate()
112+
}
113+
96114
hostConnected() {
97-
/**
98-
* expect application to be served from backend
99-
*/
100115
const wsUrl = `ws://${window.location.host}/client`
101116
console.log(`Connecting to ${wsUrl}`)
102117
const ws = (this.#ws = new WebSocket(wsUrl))
103118

104-
/**
105-
* if a connection to the backend is established we can
106-
* start fetching data
107-
*/
108119
ws.addEventListener('open', () => {
109120
this.hasConnectionProvider.setValue(true)
110121
ws.addEventListener('message', this.#handleSocketMessage.bind(this))
111122
return this.#host.requestUpdate()
112123
})
113124

114-
/**
115-
* otherwise attempt to load cached trace file
116-
*/
117125
ws.addEventListener('error', () => {
118126
try {
119127
const localStorageValue = JSON.parse(
@@ -130,7 +138,8 @@ export class DataManagerController implements ReactiveController {
130138

131139
hostDisconnected() {
132140
if (this.#ws) {
133-
return this.#ws.close()
141+
this.#ws.close()
142+
this.#ws = undefined
134143
}
135144
}
136145

@@ -141,47 +150,29 @@ export class DataManagerController implements ReactiveController {
141150
return
142151
}
143152

153+
// Check for new run BEFORE processing suites data
154+
if (scope === 'suites') {
155+
const shouldReset = this.#shouldResetForNewRun(data)
156+
if (shouldReset) {
157+
this.#resetExecutionData()
158+
}
159+
}
160+
161+
// Route data to appropriate handler
144162
if (scope === 'mutations') {
145-
this.mutationsContextProvider.setValue([
146-
...this.mutationsContextProvider.value,
147-
...(data as TraceMutation[])
148-
])
163+
this.#handleMutationsUpdate(data as TraceMutation[])
149164
} else if (scope === 'commands') {
150-
this.commandsContextProvider.setValue([
151-
...this.commandsContextProvider.value,
152-
...(data as CommandLog[])
153-
])
165+
this.#handleCommandsUpdate(data as CommandLog[])
154166
} else if (scope === 'metadata') {
155-
this.metadataContextProvider.setValue({
156-
...this.metadataContextProvider.value,
157-
...(data as Metadata)
158-
})
167+
this.#handleMetadataUpdate(data as Metadata)
159168
} else if (scope === 'consoleLogs') {
160-
this.consoleLogsContextProvider.setValue([
161-
...this.consoleLogsContextProvider.value,
162-
...(data as string[])
163-
])
169+
this.#handleConsoleLogsUpdate(data as string[])
164170
} else if (scope === 'sources') {
165-
const merged = {
166-
...(this.sourcesContextProvider.value || {}),
167-
...(data as Record<string, string>)
168-
}
169-
this.sourcesContextProvider.setValue(merged)
170-
console.debug('Merged sources keys', Object.keys(merged))
171+
this.#handleSourcesUpdate(data as Record<string, string>)
172+
} else if (scope === 'suites') {
173+
this.#handleSuitesUpdate(data)
171174
} else {
172-
const providerMap = {
173-
mutations: this.mutationsContextProvider,
174-
logs: this.logsContextProvider,
175-
consoleLogs: this.consoleLogsContextProvider,
176-
metadata: this.metadataContextProvider,
177-
commands: this.commandsContextProvider,
178-
sources: this.sourcesContextProvider,
179-
suites: this.suitesContextProvider
180-
} as const
181-
const provider = providerMap[scope as keyof typeof providerMap]
182-
if (provider) {
183-
provider.setValue(data as any)
184-
}
175+
this.#handleGenericUpdate(scope, data)
185176
}
186177

187178
this.#host.requestUpdate()
@@ -190,6 +181,201 @@ export class DataManagerController implements ReactiveController {
190181
}
191182
}
192183

184+
#shouldResetForNewRun(data: unknown): boolean {
185+
const payloads = Array.isArray(data)
186+
? (data as Record<string, SuiteStatsFragment>[])
187+
: ([data] as Record<string, SuiteStatsFragment>[])
188+
189+
for (const chunk of payloads) {
190+
if (!chunk) continue
191+
192+
for (const suite of Object.values(chunk)) {
193+
if (!suite?.start) continue
194+
195+
const suiteStartTime = suite.start instanceof Date
196+
? suite.start.getTime()
197+
: (typeof suite.start === 'number' ? suite.start : 0)
198+
199+
// New run detected if we see a newer start timestamp
200+
if (suiteStartTime > this.#lastSeenRunTimestamp) {
201+
this.#lastSeenRunTimestamp = suiteStartTime
202+
return true
203+
}
204+
}
205+
}
206+
return false
207+
}
208+
209+
#resetExecutionData() {
210+
// Clear ONLY execution visualization data
211+
this.mutationsContextProvider.setValue([])
212+
this.commandsContextProvider.setValue([])
213+
this.logsContextProvider.setValue([])
214+
this.consoleLogsContextProvider.setValue([])
215+
216+
// Keep suitesContextProvider intact - test list stays visible
217+
// Keep metadata and sources - they're environment-level
218+
219+
// Force synchronous re-render
220+
this.#host.requestUpdate()
221+
}
222+
223+
#handleMutationsUpdate(data: TraceMutation[]) {
224+
this.mutationsContextProvider.setValue([
225+
...(this.mutationsContextProvider.value || []),
226+
...data
227+
])
228+
}
229+
230+
#handleCommandsUpdate(data: CommandLog[]) {
231+
this.commandsContextProvider.setValue([
232+
...(this.commandsContextProvider.value || []),
233+
...data
234+
])
235+
}
236+
237+
#handleConsoleLogsUpdate(data: string[]) {
238+
this.consoleLogsContextProvider.setValue([
239+
...(this.consoleLogsContextProvider.value || []),
240+
...data
241+
])
242+
}
243+
244+
#handleMetadataUpdate(data: Metadata) {
245+
this.metadataContextProvider.setValue({
246+
...this.metadataContextProvider.value,
247+
...data
248+
})
249+
}
250+
251+
#handleSourcesUpdate(data: Record<string, string>) {
252+
const merged = {
253+
...(this.sourcesContextProvider.value || {}),
254+
...data
255+
}
256+
this.sourcesContextProvider.setValue(merged)
257+
console.debug('Merged sources keys', Object.keys(merged))
258+
}
259+
260+
#handleSuitesUpdate(data: unknown) {
261+
const payloads = Array.isArray(data)
262+
? (data as Record<string, SuiteStatsFragment>[])
263+
: ([data] as Record<string, SuiteStatsFragment>[])
264+
265+
const suiteMap = new Map<string, SuiteStatsFragment>()
266+
267+
// Populate with existing suites (keeps test list visible)
268+
;(this.suitesContextProvider.value || []).forEach((chunk) => {
269+
Object.entries(chunk as Record<string, SuiteStatsFragment>).forEach(
270+
([uid, suite]) => {
271+
if (suite?.uid) {
272+
suiteMap.set(uid, suite)
273+
}
274+
}
275+
)
276+
})
277+
278+
// Process incoming payloads
279+
payloads.forEach((chunk) => {
280+
if (!chunk) return
281+
282+
Object.entries(chunk).forEach(([uid, suite]) => {
283+
if (!suite?.uid) return
284+
285+
const existing = suiteMap.get(uid)
286+
287+
// Always merge to preserve all tests in the suite
288+
suiteMap.set(uid, existing ? this.#mergeSuite(existing, suite) : suite)
289+
})
290+
})
291+
292+
this.suitesContextProvider.setValue(
293+
Array.from(suiteMap.entries()).map(([uid, suite]) => ({ [uid]: suite }))
294+
)
295+
}
296+
297+
#getTimestamp(date: Date | number | undefined): number {
298+
if (!date) return 0
299+
return date instanceof Date ? date.getTime() : date
300+
}
301+
302+
#handleGenericUpdate(scope: keyof TraceLog, data: any) {
303+
const providerMap = {
304+
mutations: this.mutationsContextProvider,
305+
logs: this.logsContextProvider,
306+
consoleLogs: this.consoleLogsContextProvider,
307+
metadata: this.metadataContextProvider,
308+
commands: this.commandsContextProvider,
309+
sources: this.sourcesContextProvider,
310+
suites: this.suitesContextProvider
311+
} as const
312+
313+
const provider = providerMap[scope as keyof typeof providerMap]
314+
if (provider) {
315+
provider.setValue(data)
316+
}
317+
}
318+
319+
#mergeSuite(existing: SuiteStatsFragment, incoming: SuiteStatsFragment) {
320+
// Note: Rerun detection and clearing is now handled in #handleSuitesUpdate
321+
// before any merges happen, so data is cleared proactively
322+
323+
// First merge tests and suites properly
324+
const mergedTests = this.#mergeTests(existing.tests, incoming.tests)
325+
const mergedSuites = this.#mergeChildSuites(existing.suites, incoming.suites)
326+
327+
// Then merge suite properties, ensuring merged tests/suites are preserved
328+
const { tests: _incomingTests, suites: _incomingSuites, ...incomingProps } = incoming
329+
330+
return {
331+
...existing,
332+
...incomingProps,
333+
tests: mergedTests,
334+
suites: mergedSuites
335+
}
336+
}
337+
338+
#mergeChildSuites(
339+
prev: SuiteStatsFragment[] = [],
340+
next: SuiteStatsFragment[] = []
341+
) {
342+
const map = new Map<string, SuiteStatsFragment>()
343+
prev?.forEach((suite) => suite && map.set(suite.uid, suite))
344+
345+
next?.forEach((suite) => {
346+
if (!suite) return
347+
const existing = map.get(suite.uid)
348+
map.set(suite.uid, existing ? this.#mergeSuite(existing, suite) : suite)
349+
})
350+
351+
return Array.from(map.values())
352+
}
353+
354+
#mergeTests(
355+
prev: TestStatsFragment[] = [],
356+
next: TestStatsFragment[] = []
357+
) {
358+
const map = new Map<string, TestStatsFragment>()
359+
prev?.forEach((test) => test && map.set(test.uid, test))
360+
361+
next?.forEach((test) => {
362+
if (!test) return
363+
const existing = map.get(test.uid)
364+
365+
// Check if this test is a rerun (different start time)
366+
const isRerun =
367+
existing &&
368+
test.start &&
369+
existing.start &&
370+
this.#getTimestamp(test.start) !== this.#getTimestamp(existing.start)
371+
372+
// Replace on rerun, merge on normal update
373+
map.set(test.uid, isRerun ? test : existing ? { ...existing, ...test } : test)
374+
})
375+
376+
return Array.from(map.values())
377+
}
378+
193379
loadTraceFile(traceFile: TraceLog) {
194380
localStorage.setItem(CACHE_ID, JSON.stringify(traceFile))
195381
this.mutationsContextProvider.setValue(traceFile.mutations)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +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>
2627
}

0 commit comments

Comments
 (0)