-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: experimental toMatchAriaSnapshot (#33014)
- Loading branch information
1 parent
6cfcbe0
commit a38ff6e
Showing
22 changed files
with
716 additions
and
149 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -103,6 +103,6 @@ | |
"vite": "^5.4.6", | ||
"ws": "^8.17.1", | ||
"xml2js": "^0.5.0", | ||
"yaml": "^2.2.2" | ||
"yaml": "^2.5.1" | ||
} | ||
} |
281 changes: 281 additions & 0 deletions
281
packages/playwright-core/src/server/injected/ariaSnapshot.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,281 @@ | ||
/** | ||
* Copyright (c) Microsoft Corporation. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import { escapeWithQuotes } from '@isomorphic/stringUtils'; | ||
import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, isElementIgnoredForAria } from './roleUtils'; | ||
import { isElementVisible } from './domUtils'; | ||
|
||
type AriaNode = { | ||
role: string; | ||
name?: string; | ||
children?: (AriaNode | string)[]; | ||
}; | ||
|
||
export type AriaTemplateNode = { | ||
role: string; | ||
name?: RegExp | string; | ||
children?: (AriaTemplateNode | string | RegExp)[]; | ||
}; | ||
|
||
export function generateAriaTree(rootElement: Element): AriaNode { | ||
const toAriaNode = (element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null => { | ||
const role = getAriaRole(element); | ||
if (!role) | ||
return null; | ||
|
||
const name = role ? getElementAccessibleName(element, false) || undefined : undefined; | ||
const isLeaf = leafRoles.has(role); | ||
const result: AriaNode = { role, name }; | ||
if (isLeaf && !name && element.textContent) | ||
result.children = [element.textContent]; | ||
return { isLeaf, ariaNode: result }; | ||
}; | ||
|
||
const visit = (ariaNode: AriaNode, node: Node) => { | ||
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { | ||
ariaNode.children = ariaNode.children || []; | ||
ariaNode.children.push(node.nodeValue); | ||
return; | ||
} | ||
|
||
if (node.nodeType !== Node.ELEMENT_NODE) | ||
return; | ||
|
||
const element = node as Element; | ||
if (isElementIgnoredForAria(element)) | ||
return; | ||
|
||
const visible = isElementVisible(element); | ||
const hasVisibleChildren = element.checkVisibility({ | ||
opacityProperty: true, | ||
visibilityProperty: true, | ||
contentVisibilityAuto: true | ||
}); | ||
|
||
if (!hasVisibleChildren) | ||
return; | ||
|
||
if (visible) { | ||
const childAriaNode = toAriaNode(element); | ||
const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role); | ||
if (childAriaNode && !isHiddenContainer) { | ||
ariaNode.children = ariaNode.children || []; | ||
ariaNode.children.push(childAriaNode.ariaNode); | ||
} | ||
if (isHiddenContainer || !childAriaNode?.isLeaf) | ||
processChildNodes(childAriaNode?.ariaNode || ariaNode, element); | ||
} else { | ||
processChildNodes(ariaNode, element); | ||
} | ||
}; | ||
|
||
function processChildNodes(ariaNode: AriaNode, element: Element) { | ||
// Process light DOM children | ||
for (let child = element.firstChild; child; child = child.nextSibling) | ||
visit(ariaNode, child); | ||
// Process shadow DOM children, if any | ||
if (element.shadowRoot) { | ||
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) | ||
visit(ariaNode, child); | ||
} | ||
} | ||
|
||
beginAriaCaches(); | ||
const result = toAriaNode(rootElement); | ||
const ariaRoot = result?.ariaNode || { role: '' }; | ||
try { | ||
visit(ariaRoot, rootElement); | ||
} finally { | ||
endAriaCaches(); | ||
} | ||
|
||
normalizeStringChildren(ariaRoot); | ||
return ariaRoot; | ||
} | ||
|
||
export function renderedAriaTree(rootElement: Element): string { | ||
return renderAriaTree(generateAriaTree(rootElement)); | ||
} | ||
|
||
function normalizeStringChildren(rootA11yNode: AriaNode) { | ||
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => { | ||
if (!buffer.length) | ||
return; | ||
const text = normalizeWhitespaceWithin(buffer.join('')).trim(); | ||
if (text) | ||
normalizedChildren.push(text); | ||
buffer.length = 0; | ||
}; | ||
|
||
const visit = (ariaNode: AriaNode) => { | ||
const normalizedChildren: (AriaNode | string)[] = []; | ||
const buffer: string[] = []; | ||
for (const child of ariaNode.children || []) { | ||
if (typeof child === 'string') { | ||
buffer.push(child); | ||
} else { | ||
flushChildren(buffer, normalizedChildren); | ||
visit(child); | ||
normalizedChildren.push(child); | ||
} | ||
} | ||
flushChildren(buffer, normalizedChildren); | ||
ariaNode.children = normalizedChildren.length ? normalizedChildren : undefined; | ||
}; | ||
visit(rootA11yNode); | ||
} | ||
|
||
const hiddenContainerRoles = new Set(['none', 'presentation']); | ||
|
||
const leafRoles = new Set([ | ||
'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader', | ||
'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion', | ||
'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option', | ||
'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator', | ||
'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term', | ||
'textbox', 'time', 'tooltip' | ||
]); | ||
|
||
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\n]+/g, ' '); | ||
|
||
function matchesText(text: string | undefined, template: RegExp | string | undefined) { | ||
if (!template) | ||
return true; | ||
if (!text) | ||
return false; | ||
if (typeof template === 'string') | ||
return text === template; | ||
return !!text.match(template); | ||
} | ||
|
||
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } { | ||
const root = generateAriaTree(rootElement); | ||
const matches = nodeMatches(root, template); | ||
return { matches, received: renderAriaTree(root) }; | ||
} | ||
|
||
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean { | ||
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp)) | ||
return matchesText(node, template); | ||
|
||
if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) { | ||
if (template.role && template.role !== node.role) | ||
return false; | ||
if (!matchesText(node.name, template.name)) | ||
return false; | ||
if (!containsList(node.children || [], template.children || [], depth)) | ||
return false; | ||
return true; | ||
} | ||
return false; | ||
} | ||
|
||
function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | RegExp | string)[], depth: number): boolean { | ||
if (template.length > children.length) | ||
return false; | ||
const cc = children.slice(); | ||
const tt = template.slice(); | ||
for (const t of tt) { | ||
let c = cc.shift(); | ||
while (c) { | ||
if (matchesNode(c, t, depth + 1)) | ||
break; | ||
c = cc.shift(); | ||
} | ||
if (!c) | ||
return false; | ||
} | ||
return true; | ||
} | ||
|
||
function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean { | ||
const results: (AriaNode | string)[] = []; | ||
const visit = (node: AriaNode | string): boolean => { | ||
if (matchesNode(node, template, 0)) { | ||
results.push(node); | ||
return true; | ||
} | ||
if (typeof node === 'string') | ||
return false; | ||
for (const child of node.children || []) { | ||
if (visit(child)) | ||
return true; | ||
} | ||
return false; | ||
}; | ||
visit(root); | ||
return !!results.length; | ||
} | ||
|
||
export function renderAriaTree(ariaNode: AriaNode): string { | ||
const lines: string[] = []; | ||
const visit = (ariaNode: AriaNode, indent: string) => { | ||
let line = `${indent}- ${ariaNode.role}`; | ||
if (ariaNode.name) | ||
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`; | ||
const noChild = !ariaNode.name && !ariaNode.children?.length; | ||
const oneChild = !ariaNode.name && ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string'; | ||
if (noChild || oneChild) { | ||
if (oneChild) | ||
line += ': ' + escapeYamlString(ariaNode.children?.[0] as string); | ||
lines.push(line); | ||
return; | ||
} | ||
lines.push(line + (ariaNode.children ? ':' : '')); | ||
for (const child of ariaNode.children || []) { | ||
if (typeof child === 'string') | ||
lines.push(indent + ' - text: ' + escapeYamlString(child)); | ||
else | ||
visit(child, indent + ' '); | ||
} | ||
}; | ||
visit(ariaNode, ''); | ||
return lines.join('\n'); | ||
} | ||
|
||
function escapeYamlString(str: string) { | ||
if (str === '') | ||
return '""'; | ||
|
||
const needQuotes = ( | ||
// Starts or ends with whitespace | ||
/^\s|\s$/.test(str) || | ||
// Contains control characters | ||
/[\x00-\x1f]/.test(str) || | ||
// Contains special YAML characters that could cause parsing issues | ||
/[\[\]{}&*!,|>%@`]/.test(str) || | ||
// Contains a colon followed by a space (could be interpreted as a key-value pair) | ||
/:\s/.test(str) || | ||
// Is a YAML boolean or null value | ||
/^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/.test(str) || | ||
// Could be interpreted as a number | ||
/^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/.test(str) || | ||
// Contains a newline character | ||
/\n/.test(str) || | ||
// Starts with a special character | ||
/^[\-?:,>|%@"`]/.test(str) | ||
); | ||
|
||
if (needQuotes) { | ||
return `"${str | ||
.replace(/\\/g, '\\\\') | ||
.replace(/"/g, '\\"') | ||
.replace(/\n/g, '\\n') | ||
.replace(/\r/g, '\\r')}"`; | ||
} | ||
|
||
return str; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.