@@ -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
910const 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+
1123export const mutationContext = createContext < TraceMutation [ ] > (
1224 Symbol ( 'mutationContext' )
1325)
@@ -38,6 +50,7 @@ interface SocketMessage<T extends keyof TraceLog = keyof TraceLog> {
3850export 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 )
0 commit comments