1414 * limitations under the License.
1515 */
1616
17- import { escapeHTMLAttribute , escapeHTML } from '../stringUtils' ;
17+ import { HtmlBuilder } from '../stringUtils' ;
1818
1919import type { FrameSnapshot , NodeNameAttributesChildNodesSnapshot , NodeSnapshot , ResourceSnapshot , SubtreeReferenceSnapshot } from './trace' ;
2020import type { PageEntry } from './entries' ;
@@ -91,11 +91,11 @@ export class SnapshotRenderer {
9191 }
9292
9393 render ( ) : RenderedFrameSnapshot {
94- const result : string [ ] = [ ] ;
94+ const result = new HtmlBuilder ( ) ;
9595 const visit = ( n : NodeSnapshot , snapshotIndex : number , parentTag : string | undefined , parentAttrs : [ string , string ] [ ] | undefined ) => {
9696 // Text node.
9797 if ( typeof n === 'string' ) {
98- result . push ( escapeHTML ( n ) ) ;
98+ result . text ( n ) ;
9999 return ;
100100 }
101101
@@ -114,17 +114,14 @@ export class SnapshotRenderer {
114114 // crafted trace file could include them to achieve XSS.
115115 if ( name . toUpperCase ( ) === 'SCRIPT' )
116116 return ;
117- // Reject names that would emit extra attributes (space, =, <, >, /).
118- // Allow Unicode custom elements such as math-α.
119- if ( / [ \s = < > \/ ] / . test ( name ) )
120- return ;
121117 // Element node.
122118 // Note that <noscript> will not be rendered by default in the trace viewer, because
123119 // JS is enabled. So rename it to <x-noscript>.
124120 const upperName = name . toUpperCase ( ) ;
125121 const nodeName = upperName === 'NOSCRIPT' ? 'X-NOSCRIPT' : name ;
126122 const attrs = Object . entries ( nodeAttrs || { } ) ;
127- result . push ( '<' , nodeName ) ;
123+ if ( ! result . openTag ( nodeName ) )
124+ return ;
128125 const kCurrentSrcAttribute = '__playwright_current_src__' ;
129126 const isFrame = upperName === 'IFRAME' || upperName === 'FRAME' ;
130127 const isAnchor = upperName === 'A' ;
@@ -176,20 +173,21 @@ export class SnapshotRenderer {
176173 let attrValue = value ;
177174 if ( ! isAnchor && ( attr . toLowerCase ( ) === 'href' || attr . toLowerCase ( ) === 'src' || attr === kCurrentSrcAttribute ) )
178175 attrValue = rewriteURLForCustomProtocol ( value ) ;
179- result . push ( ' ' , attrName , '="' , escapeHTMLAttribute ( attrValue ) , '"' ) ;
176+ result . attribute ( attrName , attrValue ) ;
180177 }
181178 if ( upperName === 'STYLE' ) {
182179 // Style has always exactly one child which is a text node.
183180 const styleContent = typeof children [ 0 ] === 'string' ? children [ 0 ] : '' ;
184- result . push ( ' ' , '__playwright_style_content__' , '="' , escapeHTMLAttribute ( rewriteURLsInStyleSheetForCustomProtocol ( styleContent ) ) , '"' ) ;
185- result . push ( '></' , nodeName , '>' ) ;
181+ result . attribute ( '__playwright_style_content__' , rewriteURLsInStyleSheetForCustomProtocol ( styleContent ) ) ;
182+ result . endOpenTag ( ) ;
183+ result . closeTag ( nodeName ) ;
186184 return ;
187185 }
188- result . push ( '>' ) ;
186+ result . endOpenTag ( ) ;
189187 for ( const child of children )
190188 visit ( child , snapshotIndex , nodeName , attrs ) ;
191189 if ( ! autoClosing . has ( nodeName ) )
192- result . push ( '</' , nodeName , '>' ) ;
190+ result . closeTag ( nodeName ) ;
193191 return ;
194192 } else {
195193 // Why are we here? Let's not throw, just in case.
@@ -211,7 +209,7 @@ export class SnapshotRenderer {
211209 // Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
212210 '<style>*,*::before,*::after { visibility: hidden }</style>' ,
213211 `<script nonce="${ scriptNonce } ">${ snapshotScript ( this . viewport ( ) , this . _callId , this . snapshotName ) } </script>`
214- ] . join ( '' ) + result . join ( '' ) ;
212+ ] . join ( '' ) + result . toString ( ) ;
215213 return { value : { html, scriptNonce } , size : html . length } ;
216214 } ) ;
217215
0 commit comments