@@ -44,6 +44,75 @@ export interface ReplayTranscript {
4444const DEFAULT_MAX_BYTES_SUMMARY = 4 * 1024
4545const DEFAULT_MAX_BYTES_DETAILED = 16 * 1024
4646
47+ interface NodeMeta {
48+ tag : string
49+ attrs : Record < string , string >
50+ text : string | null
51+ parent : number | null
52+ }
53+
54+ function buildDomMapFromFullSnapshot ( events : RrwebEvent [ ] ) : Map < number , NodeMeta > {
55+ const map = new Map < number , NodeMeta > ( )
56+ const fullSnapshot = events . find ( ( e ) => e . type === 2 )
57+ if ( ! fullSnapshot ) return map
58+ const root = ( fullSnapshot . data as { node ?: unknown } ) . node
59+ if ( ! root || typeof root !== "object" ) return map
60+ walkNode ( root as Record < string , unknown > , null , map )
61+ return map
62+ }
63+
64+ function walkNode (
65+ node : Record < string , unknown > ,
66+ parentId : number | null ,
67+ out : Map < number , NodeMeta > ,
68+ ) : void {
69+ const id = typeof node . id === "number" ? node . id : null
70+ const type = typeof node . type === "number" ? node . type : null
71+ if ( id != null && type === 2 ) {
72+ // Element
73+ out . set ( id , {
74+ tag : typeof node . tagName === "string" ? node . tagName . toLowerCase ( ) : "unknown" ,
75+ attrs : ( node . attributes as Record < string , string > ) ?? { } ,
76+ text : null ,
77+ parent : parentId ,
78+ } )
79+ } else if ( id != null && type === 3 && typeof node . textContent === "string" ) {
80+ // Text
81+ out . set ( id , { tag : "#text" , attrs : { } , text : node . textContent , parent : parentId } )
82+ }
83+ const children = Array . isArray ( node . childNodes ) ? ( node . childNodes as unknown [ ] ) : [ ]
84+ for ( const child of children ) {
85+ if ( child && typeof child === "object" ) {
86+ walkNode ( child as Record < string , unknown > , id , out )
87+ }
88+ }
89+ }
90+
91+ function resolveSelector ( id : number , dom : Map < number , NodeMeta > ) : string {
92+ const node = dom . get ( id )
93+ if ( ! node ) return `<unknown element ${ id } >`
94+ if ( node . tag === "#text" ) return `text "${ ( node . text ?? "" ) . trim ( ) . slice ( 0 , 40 ) } "`
95+ const name = node . attrs . name
96+ const ariaLabel = node . attrs [ "aria-label" ]
97+ const type = node . attrs . type
98+ const className = node . attrs . class ?. split ( / \s + / ) . find ( Boolean )
99+ const innerText = collectInnerText ( id , dom ) . trim ( ) . slice ( 0 , 40 )
100+ if ( name ) return `${ node . tag } [name="${ name } "]`
101+ if ( ariaLabel ) return `${ node . tag } [aria-label="${ ariaLabel } "]`
102+ if ( type && ( node . tag === "button" || node . tag === "input" ) ) return `${ node . tag } [type="${ type } "]`
103+ if ( innerText && ( node . tag === "button" || node . tag === "a" ) ) return `${ node . tag } "${ innerText } "`
104+ if ( className ) return `${ node . tag } .${ className } `
105+ return `<${ node . tag } >`
106+ }
107+
108+ function collectInnerText ( id : number , dom : Map < number , NodeMeta > ) : string {
109+ let out = ""
110+ for ( const [ , meta ] of dom ) {
111+ if ( meta . parent === id && meta . tag === "#text" ) out += meta . text ?? ""
112+ }
113+ return out
114+ }
115+
47116export function buildReplayTranscript (
48117 events : RrwebEvent [ ] ,
49118 opts : BuildReplayTranscriptOptions ,
@@ -56,17 +125,35 @@ export function buildReplayTranscript(
56125 truncated : false ,
57126 }
58127 }
59- // Real reduction lands in subsequent tasks. For now the empty-stream branch
60- // is the only branch any test exercises. maxBytes is wired here so later
61- // tasks can reference it without altering the function signature.
128+ // maxBytes is wired here so later tasks (Task 6) can reference it without
129+ // altering the function signature.
62130 const maxBytes =
63131 opts . maxBytes ??
64132 ( opts . verbosity === "summary" ? DEFAULT_MAX_BYTES_SUMMARY : DEFAULT_MAX_BYTES_DETAILED )
65133 void maxBytes
134+
135+ const dom = buildDomMapFromFullSnapshot ( events )
136+ const startTs = events [ 0 ] ! . timestamp
137+ const endTs = events [ events . length - 1 ] ! . timestamp
138+ const lines : string [ ] = [ ]
139+
140+ for ( const e of events ) {
141+ if ( e . type === 3 && ( e . data as { source ?: number } ) . source === 2 ) {
142+ const id = ( e . data as { id ?: number } ) . id
143+ if ( typeof id !== "number" ) continue
144+ const t = ( ( e . timestamp - startTs ) / 1000 ) . toFixed ( 1 )
145+ lines . push ( `[+${ t } s] click ${ resolveSelector ( id , dom ) } ` )
146+ }
147+ }
148+
149+ const transcript =
150+ `Replay (${ ( ( endTs - startTs ) / 1000 ) . toFixed ( 1 ) } s, ${ events . length } events)\n\n` +
151+ lines . join ( "\n" )
152+
66153 return {
67- transcript : `Replay ( ${ events . length } events)` ,
154+ transcript,
68155 eventCount : events . length ,
69- durationMs : events [ events . length - 1 ] ! . timestamp - events [ 0 ] ! . timestamp ,
156+ durationMs : endTs - startTs ,
70157 truncated : false ,
71158 }
72159}
0 commit comments