1515 */
1616
1717import * as aria from '@isomorphic/ariaSnapshot' ;
18- import { escapeRegExp , longestCommonSubstring , normalizeWhiteSpace , truncateDataUrl } from '@isomorphic/stringUtils ' ;
19- import { yamlEscapeKeyIfNeeded , yamlEscapeValueIfNeeded } from '@isomorphic/yaml ' ;
18+ import { renderAriaSnapshotAsYaml } from '@isomorphic/ariaSnapshotRenderer ' ;
19+ import { normalizeWhiteSpace , truncateDataUrl } from '@isomorphic/stringUtils ' ;
2020
2121import { distillAriaSnapshot } from './ariaSnapshotDistiller' ;
2222import { computeBox , getElementComputedStyle , isElementVisible } from './domUtils' ;
@@ -52,7 +52,6 @@ type InternalOptions = {
5252 includeGenericRole ?: boolean ,
5353 renderCursorPointer ?: boolean ,
5454 renderActive ?: boolean ,
55- renderStringsAsRegex ?: boolean ,
5655 renderBoxes ?: boolean ,
5756} ;
5857
@@ -74,11 +73,8 @@ function toInternalOptions(options: AriaTreeOptions): InternalOptions {
7473 // To auto-generate assertions on visible elements.
7574 return { visibility : 'ariaAndVisible' , refs : 'none' , renderBoxes } ;
7675 }
77- if ( options . mode === 'codegen' ) {
78- // To generate aria assertion with regex heurisitcs.
79- return { visibility : 'aria' , refs : 'none' , renderStringsAsRegex : true , renderBoxes } ;
80- }
81- // To match aria snapshot.
76+ // To match aria snapshot. In 'codegen' mode, the generated tree is the same,
77+ // strings are converted to regexes when serializing to yaml.
8278 return { visibility : 'aria' , refs : 'none' , renderBoxes } ;
8379}
8480
@@ -360,11 +356,12 @@ export type MatcherReceived = {
360356export function matchesExpectAriaTemplate ( rootElement : Element , template : aria . AriaTemplateNode ) : { matches : aria . AriaNode [ ] , received : MatcherReceived } {
361357 const snapshot = generateAriaTree ( rootElement , { mode : 'default' } ) ;
362358 const matches = matchesNodeDeep ( snapshot . root , template , false , false ) ;
359+ const { json } = renderAriaTreeAsJSON ( snapshot , { mode : 'default' } ) ;
363360 return {
364361 matches,
365362 received : {
366- raw : renderAriaTree ( snapshot , { mode : 'default' } ) . text ,
367- regex : renderAriaTree ( snapshot , { mode : 'codegen' } ) . text ,
363+ raw : renderAriaSnapshotAsYaml ( json ) ,
364+ regex : renderAriaSnapshotAsYaml ( json , { convertStringsToRegex : true } ) ,
368365 }
369366 } ;
370367}
@@ -462,127 +459,6 @@ function matchesNodeDeep(root: aria.AriaNode, template: aria.AriaTemplateNode, c
462459 return results ;
463460}
464461
465- function indent ( depth : number ) : string {
466- return ' ' . repeat ( depth ) ;
467- }
468-
469- export function renderAriaTree ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions ) : { text : string , iframeDepths : Record < string , number > } {
470- const options = toInternalOptions ( publicOptions ) ;
471- const lines : string [ ] = [ ] ;
472- const iframeDepths : Record < string , number > = { } ;
473- const includeText = options . renderStringsAsRegex ? textContributesInfo : ( ) => true ;
474- const renderString = options . renderStringsAsRegex ? convertToBestGuessRegex : ( str : string ) => str ;
475-
476- // Do not render the root fragment, just its children.
477- const nodesToRender = ariaSnapshot . root . role === 'fragment' ? ariaSnapshot . root . children : [ ariaSnapshot . root ] ;
478-
479- const visitText = ( text : string , depth : number ) => {
480- if ( publicOptions . depth && depth > publicOptions . depth )
481- return ;
482- const escaped = yamlEscapeValueIfNeeded ( renderString ( text ) ) ;
483- if ( escaped )
484- lines . push ( indent ( depth ) + '- text: ' + escaped ) ;
485- } ;
486-
487- const createKey = ( ariaNode : aria . AriaNode , renderCursorPointer : boolean ) : string => {
488- let key = ariaNode . role ;
489- // Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
490- if ( ariaNode . name && ariaNode . name . length <= 900 ) {
491- const name = renderString ( ariaNode . name ) ;
492- if ( name ) {
493- const stringifiedName = name . startsWith ( '/' ) && name . endsWith ( '/' ) ? name : JSON . stringify ( name ) ;
494- key += ' ' + stringifiedName ;
495- }
496- }
497- if ( ariaNode . checked === 'mixed' )
498- key += ` [checked=mixed]` ;
499- if ( ariaNode . checked === true )
500- key += ` [checked]` ;
501- if ( ariaNode . disabled )
502- key += ` [disabled]` ;
503- if ( ariaNode . expanded )
504- key += ` [expanded]` ;
505- if ( ariaNode . active && options . renderActive )
506- key += ` [active]` ;
507- if ( ariaNode . invalid === 'grammar' || ariaNode . invalid === 'spelling' )
508- key += ` [invalid=${ ariaNode . invalid } ]` ;
509- if ( ariaNode . invalid === true )
510- key += ` [invalid]` ;
511- if ( ariaNode . level )
512- key += ` [level=${ ariaNode . level } ]` ;
513- if ( ariaNode . pressed === 'mixed' )
514- key += ` [pressed=mixed]` ;
515- if ( ariaNode . pressed === true )
516- key += ` [pressed]` ;
517- if ( ariaNode . selected === true )
518- key += ` [selected]` ;
519-
520- if ( ariaNode . ref ) {
521- key += ` [ref=${ ariaNode . ref } ]` ;
522- if ( renderCursorPointer && aria . hasPointerCursor ( ariaNode ) )
523- key += ' [cursor=pointer]' ;
524- }
525- if ( options . renderBoxes ) {
526- const element = ariaNodeElement ( ariaNode ) ;
527- if ( element ) {
528- const r = element . getBoundingClientRect ( ) ;
529- key += ` [box=${ Math . round ( r . x ) } ,${ Math . round ( r . y ) } ,${ Math . round ( r . width ) } ,${ Math . round ( r . height ) } ]` ;
530- }
531- }
532- return key ;
533- } ;
534-
535- const getSingleTextChild = ( ariaNode : aria . AriaNode ) : string | undefined => {
536- return ariaNode . children . length === 1 && typeof ariaNode . children [ 0 ] === 'string' && ! Object . keys ( ariaNode . props ) . length ? ariaNode . children [ 0 ] : undefined ;
537- } ;
538-
539- const visit = ( ariaNode : aria . AriaNode , depth : number , renderCursorPointer : boolean ) => {
540- if ( publicOptions . depth && depth > publicOptions . depth )
541- return ;
542-
543- if ( ariaNode . role === 'iframe' && ariaNode . ref )
544- iframeDepths [ ariaNode . ref ] = depth ;
545-
546- const escapedKey = indent ( depth ) + '- ' + yamlEscapeKeyIfNeeded ( createKey ( ariaNode , renderCursorPointer ) ) ;
547- const singleTextChild = getSingleTextChild ( ariaNode ) ;
548- const isAtDepthLimit = ! ! publicOptions . depth && depth === publicOptions . depth ;
549- const hasNoChildren = ! singleTextChild && ( ! ariaNode . children . length || isAtDepthLimit ) ;
550-
551- if ( hasNoChildren && ! Object . keys ( ariaNode . props ) . length ) {
552- // Leaf node without children.
553- lines . push ( escapedKey ) ;
554- } else if ( singleTextChild !== undefined ) {
555- // Leaf node with just some text inside.
556- const shouldInclude = includeText ( ariaNode , singleTextChild ) ;
557- if ( shouldInclude )
558- lines . push ( escapedKey + ': ' + yamlEscapeValueIfNeeded ( renderString ( singleTextChild ) ) ) ;
559- else
560- lines . push ( escapedKey ) ;
561- } else {
562- // Node with (optional) props and some children.
563- lines . push ( escapedKey + ':' ) ;
564- for ( const [ name , value ] of Object . entries ( ariaNode . props ) )
565- lines . push ( indent ( depth + 1 ) + '- /' + name + ': ' + yamlEscapeValueIfNeeded ( value ) ) ;
566-
567- const inCursorPointer = ! ! ariaNode . ref && renderCursorPointer && aria . hasPointerCursor ( ariaNode ) ;
568- for ( const child of ariaNode . children ) {
569- if ( typeof child === 'string' )
570- visitText ( includeText ( ariaNode , child ) ? child : '' , depth + 1 ) ;
571- else
572- visit ( child , depth + 1 , renderCursorPointer && ! inCursorPointer ) ;
573- }
574- }
575- } ;
576-
577- for ( const nodeToRender of nodesToRender ) {
578- if ( typeof nodeToRender === 'string' )
579- visitText ( nodeToRender , 0 ) ;
580- else
581- visit ( nodeToRender , 0 , ! ! options . renderCursorPointer ) ;
582- }
583- return { text : lines . join ( '\n' ) , iframeDepths } ;
584- }
585-
586462export function renderAriaTreeAsJSON ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions ) : { json : aria . AriaSnapshotJSON , iframeDepths : Record < string , number > } {
587463 const options = toInternalOptions ( publicOptions ) ;
588464 const iframeDepths : Record < string , number > = { } ;
@@ -591,7 +467,7 @@ export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions:
591467 if ( ariaNode . role === 'iframe' && ariaNode . ref )
592468 iframeDepths [ ariaNode . ref ] = depth ;
593469
594- const node : aria . AriaNodeJSON = { role : ariaNode . role } ;
470+ const node : aria . AriaNodeJSON = { role : ariaNode . role as aria . AriaNodeJSON [ 'role' ] } ;
595471 if ( ariaNode . name )
596472 node . name = ariaNode . name ;
597473 if ( ariaNode . checked === 'mixed' || ariaNode . checked === true )
@@ -622,8 +498,10 @@ export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions:
622498 node . box = { x : Math . round ( r . x ) , y : Math . round ( r . y ) , width : Math . round ( r . width ) , height : Math . round ( r . height ) } ;
623499 }
624500 }
625- for ( const [ name , value ] of Object . entries ( ariaNode . props ) )
626- node [ name ] = value ;
501+ if ( ariaNode . props . url !== undefined )
502+ node . url = ariaNode . props . url ;
503+ if ( ariaNode . props . placeholder !== undefined )
504+ node . placeholder = ariaNode . props . placeholder ;
627505
628506 const singleTextChild = ariaNode . children . length === 1 && typeof ariaNode . children [ 0 ] === 'string' ? ariaNode . children [ 0 ] : undefined ;
629507 const isAtDepthLimit = ! ! publicOptions . depth && depth === publicOptions . depth ;
@@ -644,70 +522,13 @@ export function renderAriaTreeAsJSON(ariaSnapshot: AriaSnapshot, publicOptions:
644522 const nodesToRender = ariaSnapshot . root . role === 'fragment' ? ariaSnapshot . root . children : [ ariaSnapshot . root ] ;
645523 for ( const nodeToRender of nodesToRender ) {
646524 if ( typeof nodeToRender === 'string' )
647- json . push ( nodeToRender ) ;
525+ json . push ( { role : 'text' , text : nodeToRender } ) ;
648526 else
649527 json . push ( visit ( nodeToRender , 0 , ! ! options . renderCursorPointer ) ) ;
650528 }
651529 return { json, iframeDepths } ;
652530}
653531
654- function convertToBestGuessRegex ( text : string ) : string {
655- const dynamicContent = [
656- // 550e8400-e29b-41d4-a716-446655440000
657- { regex : / \b [ 0 - 9 a - f A - F ] { 8 } - [ 0 - 9 a - f A - F ] { 4 } - [ 0 - 9 a - f A - F ] { 4 } - [ 0 - 9 a - f A - F ] { 4 } - [ 0 - 9 a - f A - F ] { 12 } \b / , replacement : '[0-9a-fA-F-]+' } ,
658- // 2mb
659- { regex : / \b [ \d , . ] + [ b k m B K M ] + \b / , replacement : '[\\d,.]+[bkmBKM]+' } ,
660- // 2ms, 20s
661- { regex : / \b \d + [ h m s p ] + \b / , replacement : '\\d+[hmsp]+' } ,
662- { regex : / \b [ \d , . ] + [ h m s p ] + \b / , replacement : '[\\d,.]+[hmsp]+' } ,
663- // Do not replace single digits with regex by default.
664- // 2+ digits: [Issue 22, 22.3, 2.33, 2,333]
665- { regex : / \b \d + , \d + \b / , replacement : '\\d+,\\d+' } ,
666- { regex : / \b \d + \. \d { 2 , } \b / , replacement : '\\d+\\.\\d+' } ,
667- { regex : / \b \d { 2 , } \. \d + \b / , replacement : '\\d+\\.\\d+' } ,
668- { regex : / \b \d { 2 , } \b / , replacement : '\\d+' } ,
669- ] ;
670-
671- let pattern = '' ;
672- let lastIndex = 0 ;
673-
674- const combinedRegex = new RegExp ( dynamicContent . map ( r => '(' + r . regex . source + ')' ) . join ( '|' ) , 'g' ) ;
675- text . replace ( combinedRegex , ( match , ...args ) => {
676- const offset = args [ args . length - 2 ] ;
677- const groups = args . slice ( 0 , - 2 ) ;
678- pattern += escapeRegExp ( text . slice ( lastIndex , offset ) ) ;
679- for ( let i = 0 ; i < groups . length ; i ++ ) {
680- if ( groups [ i ] ) {
681- const { replacement } = dynamicContent [ i ] ;
682- pattern += replacement ;
683- break ;
684- }
685- }
686- lastIndex = offset + match . length ;
687- return match ;
688- } ) ;
689- if ( ! pattern )
690- return text ;
691-
692- pattern += escapeRegExp ( text . slice ( lastIndex ) ) ;
693- return String ( new RegExp ( pattern ) ) ;
694- }
695-
696- function textContributesInfo ( node : aria . AriaNode , text : string ) : boolean {
697- if ( ! text . length )
698- return false ;
699-
700- if ( ! node . name )
701- return true ;
702-
703- // Figure out if text adds any value. "longestCommonSubstring" is expensive, so limit strings length.
704- const substr = ( text . length <= 200 && node . name . length <= 200 ) ? longestCommonSubstring ( text , node . name ) : '' ;
705- let filtered = text ;
706- while ( substr && filtered . includes ( substr ) )
707- filtered = filtered . replace ( substr , '' ) ;
708- return filtered . trim ( ) . length / text . length > 0.1 ;
709- }
710-
711532const elementSymbol = Symbol ( 'element' ) ;
712533
713534function ariaNodeElement ( ariaNode : aria . AriaNode ) : Element {
0 commit comments