-
Notifications
You must be signed in to change notification settings - Fork 4.8k
chore(aria): extract compareSnapshots #37987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ | |
| * limitations under the License. | ||
| */ | ||
|
|
||
| import { ariaPropsEqual } from '@isomorphic/ariaSnapshot'; | ||
| import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils'; | ||
|
|
||
| import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils'; | ||
|
|
@@ -23,6 +24,7 @@ import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml'; | |
| import type { AriaProps, AriaRegex, AriaTextValue, AriaRole, AriaTemplateNode } from '@isomorphic/ariaSnapshot'; | ||
| import type { Box } from './domUtils'; | ||
|
|
||
| // Note: please keep in sync with ariaNodesEqual() below. | ||
| export type AriaNode = AriaProps & { | ||
| role: AriaRole | 'fragment' | 'iframe'; | ||
| name: string; | ||
|
|
@@ -34,6 +36,16 @@ export type AriaNode = AriaProps & { | |
| props: Record<string, string>; | ||
| }; | ||
|
|
||
| function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean { | ||
| if (a.role !== b.role || a.name !== b.name) | ||
| return false; | ||
| if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) | ||
| return false; | ||
| const aKeys = Object.keys(a.props); | ||
| const bKeys = Object.keys(b.props); | ||
| return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]); | ||
| } | ||
|
|
||
| export type AriaSnapshot = { | ||
| root: AriaNode; | ||
| elements: Map<string, Element>; | ||
|
|
@@ -495,7 +507,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll: | |
| return results; | ||
| } | ||
|
|
||
| function buildByRefMap(root: AriaNode | undefined, map: Map<string, AriaNode> = new Map()): Map<string, AriaNode> { | ||
| function buildByRefMap(root: AriaNode | undefined, map: Map<string | undefined, AriaNode> = new Map()): Map<string | undefined, AriaNode> { | ||
| if (root?.ref) | ||
| map.set(root.ref, root); | ||
| for (const child of root?.children || []) { | ||
|
|
@@ -505,27 +517,44 @@ function buildByRefMap(root: AriaNode | undefined, map: Map<string, AriaNode> = | |
| return map; | ||
| } | ||
|
|
||
| function hasIframeNodes(root: AriaNode): boolean { | ||
| if (root.role === 'iframe') | ||
| return true; | ||
| return (root.children || []).some(child => typeof child !== 'string' && hasIframeNodes(child)); | ||
| } | ||
| function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map<AriaNode, 'skip' | 'same' | 'changed'> { | ||
| const previousByRef = buildByRefMap(previousSnapshot?.root); | ||
| const result = new Map<AriaNode, 'same' | 'changed'>(); | ||
|
|
||
| function arePropsEqual(a: AriaNode, b: AriaNode): boolean { | ||
| const aKeys = Object.keys(a.props); | ||
| const bKeys = Object.keys(b.props); | ||
| return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]); | ||
| } | ||
| // Returns whether ariaNode is the same as previousNode. | ||
| const visit = (ariaNode: AriaNode, previousNode: AriaNode | undefined): boolean => { | ||
| let same: boolean = ariaNode.children.length === previousNode?.children.length && ariaNodesEqual(ariaNode, previousNode); | ||
| if (ariaNode.role === 'iframe') | ||
| same = false; | ||
|
|
||
| export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previous?: AriaSnapshot): string { | ||
| if (hasIframeNodes(ariaSnapshot.root)) | ||
| previous = undefined; | ||
| for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) { | ||
| const child = ariaNode.children[childIndex]; | ||
| const previousChild = previousNode?.children[childIndex]; | ||
| if (typeof child === 'string') { | ||
| same &&= child === previousChild; | ||
| } else { | ||
| let previous = typeof previousChild !== 'string' ? previousChild : undefined; | ||
| if (child.ref) | ||
| previous = previousByRef.get(child.ref); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way I read it, I can reorder child nodes and it'll yield the truthy result, yet you have it tested. Why does it work? |
||
| const sameChild = visit(child, previous); | ||
| same &&= (sameChild && previous === previousChild); | ||
| } | ||
| } | ||
|
|
||
| result.set(ariaNode, same ? 'same' : 'changed'); | ||
| return same; | ||
| }; | ||
|
|
||
| visit(ariaSnapshot.root, previousByRef.get(previousSnapshot?.root?.ref)); | ||
| return result; | ||
| } | ||
|
|
||
| export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): string { | ||
| const options = toInternalOptions(publicOptions); | ||
| const lines: string[] = []; | ||
| const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true; | ||
| const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str; | ||
| const previousByRef = buildByRefMap(previous?.root); | ||
| const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot); | ||
|
|
||
| const visitText = (text: string, indent: string) => { | ||
| const escaped = yamlEscapeValueIfNeeded(renderString(text)); | ||
|
|
@@ -574,27 +603,23 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr | |
| return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined; | ||
| }; | ||
|
|
||
| const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean, previousNode: AriaNode | undefined): { unchanged: boolean } => { | ||
| if (ariaNode.ref) | ||
| previousNode = previousByRef.get(ariaNode.ref); | ||
| const visit = (ariaNode: AriaNode, indent: string, renderCursorPointer: boolean) => { | ||
| const status = statusMap.get(ariaNode); | ||
|
|
||
| const linesBefore = lines.length; | ||
| const key = createKey(ariaNode, renderCursorPointer); | ||
| const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key); | ||
| const inCursorPointer = renderCursorPointer && !!ariaNode.ref && hasPointerCursor(ariaNode); | ||
| const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); | ||
| // Replace the whole subtree with a single reference when possible. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a part of compareSnapshots? |
||
| if (status === 'same' && ariaNode.ref) { | ||
| lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`); | ||
| return; | ||
| } | ||
|
|
||
| // Whether ariaNode's subtree is the same as previousNode's, and can be replaced with just a ref. | ||
| let unchanged = !!previousNode && key === createKey(previousNode, renderCursorPointer) && arePropsEqual(ariaNode, previousNode); | ||
| const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer)); | ||
| const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); | ||
|
|
||
| if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) { | ||
| // Leaf node without children. | ||
| lines.push(escapedKey); | ||
| } else if (singleInlinedTextChild !== undefined) { | ||
| // Leaf node with just some text inside. | ||
| // Unchanged when the previous node also had the same single text child. | ||
| unchanged = unchanged && getSingleInlinedTextChild(previousNode) === singleInlinedTextChild; | ||
|
|
||
| const shouldInclude = includeText(ariaNode, singleInlinedTextChild); | ||
| if (shouldInclude) | ||
| lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild))); | ||
|
|
@@ -605,32 +630,18 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr | |
| lines.push(escapedKey + ':'); | ||
| for (const [name, value] of Object.entries(ariaNode.props)) | ||
| lines.push(indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded(value)); | ||
|
|
||
| // All children must be the same. | ||
| unchanged = unchanged && previousNode?.children.length === ariaNode.children.length; | ||
|
|
||
| const childIndent = indent + ' '; | ||
| for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) { | ||
| const child = ariaNode.children[childIndex]; | ||
| if (typeof child === 'string') { | ||
| unchanged = unchanged && previousNode?.children[childIndex] === child; | ||
| if (includeText(ariaNode, child)) | ||
| visitText(child, childIndent); | ||
| } else { | ||
| const previousChild = previousNode?.children[childIndex]; | ||
| const childResult = visit(child, childIndent, renderCursorPointer && !inCursorPointer, typeof previousChild !== 'string' ? previousChild : undefined); | ||
| unchanged = unchanged && childResult.unchanged; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (unchanged && ariaNode.ref) { | ||
| // Replace the whole subtree with a single reference. | ||
| lines.splice(linesBefore); | ||
| lines.push(indent + `- ref=${ariaNode.ref} [unchanged]`); | ||
| indent += ' '; | ||
| if (singleInlinedTextChild === undefined) { | ||
| const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode); | ||
| for (const child of ariaNode.children) { | ||
| if (typeof child === 'string') | ||
| visitText(includeText(ariaNode, child) ? child : '', indent); | ||
| else | ||
| visit(child, indent, renderCursorPointer && !inCursorPointer); | ||
| } | ||
| } | ||
|
|
||
| return { unchanged }; | ||
| }; | ||
|
|
||
| // Do not render the root fragment, just its children. | ||
|
|
@@ -639,7 +650,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr | |
| if (typeof nodeToRender === 'string') | ||
| visitText(nodeToRender, ''); | ||
| else | ||
| visit(nodeToRender, '', !!options.renderCursorPointer, undefined); | ||
| visit(nodeToRender, '', !!options.renderCursorPointer); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm growing increasingly unsympathetic towards rendering via delta. I think we should have a simple code path that does a simple thing and a complex code path that does complex things. If we need helper functions for it, let's introduce them beforehand.