/** * 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. */ // https://www.w3.org/TR/wai-aria-1.2/#role_definitions export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' | 'complementary' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' | 'gridcell' | 'group' | 'heading' | 'img' | 'insertion' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'mark' | 'marquee' | 'math' | 'meter' | 'menu' | 'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' | 'region' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'separator' | 'slider' | 'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' | 'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem'; // Note: please keep in sync with ariaPropsEqual() below. export type AriaProps = { checked?: boolean | 'mixed'; disabled?: boolean; expanded?: boolean; active?: boolean; level?: number; pressed?: boolean | 'mixed'; selected?: boolean; }; export type AriaBox = { visible: boolean; inline: boolean; cursor?: string; }; // Note: please keep in sync with ariaNodesEqual() below. export type AriaNode = AriaProps & { role: AriaRole | 'fragment' | 'iframe'; name: string; ref?: string; children: (AriaNode | string)[]; box: AriaBox; receivesPointerEvents: boolean; props: Record; }; export 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 function hasPointerCursor(ariaNode: AriaNode): boolean { return ariaNode.box.cursor === 'pointer'; } function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean { return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed; } // We pass parsed template between worlds using JSON, make it easy. export type AriaRegex = { pattern: string }; // We can't tell apart pattern and text, so we pass both. export type AriaTextValue = { raw: string; normalized: string; }; export type AriaTemplateTextNode = { kind: 'text'; text: AriaTextValue; }; export type AriaTemplateRoleNode = AriaProps & { kind: 'role'; role: AriaRole | 'fragment'; name?: AriaRegex | string; children?: AriaTemplateNode[]; props?: Record; containerMode?: 'contain' | 'equal' | 'deep-equal'; }; export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode; import type * as yamlTypes from 'yaml'; type YamlLibrary = { parseDocument: typeof yamlTypes.parseDocument; Scalar: typeof yamlTypes.Scalar; YAMLMap: typeof yamlTypes.YAMLMap; YAMLSeq: typeof yamlTypes.YAMLSeq; LineCounter: typeof yamlTypes.LineCounter; }; type ParsedYamlPosition = { line: number; col: number; }; type ParsingOptions = yamlTypes.ParseOptions; export type ParsedYamlError = { message: string; range: [ParsedYamlPosition, ParsedYamlPosition]; }; export function parseAriaSnapshotUnsafe(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): AriaTemplateNode { const result = parseAriaSnapshot(yaml, text, options); if (result.errors.length) throw new Error(result.errors[0].message); return result.fragment; } export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: ParsingOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } { const lineCounter = new yaml.LineCounter(); const parseOptions: ParsingOptions = { keepSourceTokens: true, lineCounter, ...options, }; const yamlDoc = yaml.parseDocument(text, parseOptions); const errors: ParsedYamlError[] = []; const convertRange = (range: [number, number] | yamlTypes.Range): [ParsedYamlPosition, ParsedYamlPosition] => { return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])]; }; const addError = (error: yamlTypes.YAMLError) => { errors.push({ message: error.message, range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])], }); }; const convertSeq = (container: AriaTemplateRoleNode, seq: yamlTypes.YAMLSeq) => { for (const item of seq.items) { const itemIsString = item instanceof yaml.Scalar && typeof item.value === 'string'; if (itemIsString) { const childNode = KeyParser.parse(item, parseOptions, errors); if (childNode) { container.children = container.children || []; container.children.push(childNode); } continue; } const itemIsMap = item instanceof yaml.YAMLMap; if (itemIsMap) { convertMap(container, item); continue; } errors.push({ message: 'Sequence items should be strings or maps', range: convertRange((item as any).range || seq.range), }); } }; const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => { for (const entry of map.items) { container.children = container.children || []; // Key must by a string const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string'; if (!keyIsString) { errors.push({ message: 'Only string keys are supported', range: convertRange((entry.key as any).range || map.range), }); continue; } const key: yamlTypes.Scalar = entry.key as yamlTypes.Scalar; const value = entry.value; // - text: "text" if (key.value === 'text') { const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string'; if (!valueIsString) { errors.push({ message: 'Text value should be a string', range: convertRange(((entry.value as any).range || map.range)), }); continue; } container.children.push({ kind: 'text', text: textValue(value.value) }); continue; } // - /children: equal if (key.value === '/children') { const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string'; if (!valueIsString || (value.value !== 'contain' && value.value !== 'equal' && value.value !== 'deep-equal')) { errors.push({ message: 'Strict value should be "contain", "equal" or "deep-equal"', range: convertRange(((entry.value as any).range || map.range)), }); continue; } container.containerMode = value.value; continue; } // - /url: "about:blank" if (key.value.startsWith('/')) { const valueIsString = value instanceof yaml.Scalar && typeof value.value === 'string'; if (!valueIsString) { errors.push({ message: 'Property value should be a string', range: convertRange(((entry.value as any).range || map.range)), }); continue; } container.props = container.props ?? {}; container.props[key.value.slice(1)] = textValue(value.value); continue; } // role "name": ... const childNode = KeyParser.parse(key, parseOptions, errors); if (!childNode) continue; // - role "name": "text" const valueIsScalar = value instanceof yaml.Scalar; if (valueIsScalar) { const type = typeof value.value; if (type !== 'string' && type !== 'number' && type !== 'boolean') { errors.push({ message: 'Node value should be a string or a sequence', range: convertRange(((entry.value as any).range || map.range)), }); continue; } container.children.push({ ...childNode, children: [{ kind: 'text', text: textValue(String(value.value)) }] }); continue; } // - role "name": // - child const valueIsSequence = value instanceof yaml.YAMLSeq; if (valueIsSequence) { container.children.push(childNode); convertSeq(childNode, value as yamlTypes.YAMLSeq); continue; } errors.push({ message: 'Map values should be strings or sequences', range: convertRange((entry.value as any).range || map.range), }); } }; const fragment: AriaTemplateNode = { kind: 'role', role: 'fragment' }; yamlDoc.errors.forEach(addError); if (errors.length) return { errors, fragment }; if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) { errors.push({ message: 'Aria snapshot must be a YAML sequence, elements starting with " -"', range: yamlDoc.contents ? convertRange(yamlDoc.contents!.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }], }); } if (errors.length) return { errors, fragment }; convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq); if (errors.length) return { errors, fragment: emptyFragment }; // `- button` should target the button, not its parent. if (fragment.children?.length === 1 && (!fragment.containerMode || fragment.containerMode === 'contain')) return { fragment: fragment.children[0], errors: [] }; return { fragment, errors: [] }; } const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' }; function normalizeWhitespace(text: string) { // TODO: why is this different from normalizeWhitespace in stringUtils.ts? return text.replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim(); } export function textValue(value: string): AriaTextValue { return { raw: value, normalized: normalizeWhitespace(value), }; } export class KeyParser { private _input: string; private _pos: number; private _length: number; static parse(text: yamlTypes.Scalar, options: ParsingOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null { try { return new KeyParser(text.value)._parse(); } catch (e) { if (e instanceof ParserError) { const message = options.prettyErrors === false ? e.message : e.message + ':\n\n' + text.value + '\n' + ' '.repeat(e.pos) + '^\n'; errors.push({ message, range: [options.lineCounter!.linePos(text.range![0]), options.lineCounter!.linePos(text.range![0] + e.pos)], }); return null; } throw e; } } constructor(input: string) { this._input = input; this._pos = 0; this._length = input.length; } private _peek() { return this._input[this._pos] || ''; } private _next() { if (this._pos < this._length) return this._input[this._pos++]; return null; } private _eof() { return this._pos >= this._length; } private _isWhitespace() { return !this._eof() && /\s/.test(this._peek()); } private _skipWhitespace() { while (this._isWhitespace()) this._pos++; } private _readIdentifier(type: 'role' | 'attribute'): string { if (this._eof()) this._throwError(`Unexpected end of input when expecting ${type}`); const start = this._pos; while (!this._eof() && /[a-zA-Z]/.test(this._peek())) this._pos++; return this._input.slice(start, this._pos); } private _readString(): string { let result = ''; let escaped = false; while (!this._eof()) { const ch = this._next(); if (escaped) { result += ch; escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '"') { return result; } else { result += ch; } } this._throwError('Unterminated string'); } private _throwError(message: string, offset: number = 0): never { throw new ParserError(message, offset || this._pos); } private _readRegex(): AriaRegex { let result = ''; let escaped = false; let insideClass = false; while (!this._eof()) { const ch = this._next(); if (escaped) { result += ch; escaped = false; } else if (ch === '\\') { escaped = true; result += ch; } else if (ch === '/' && !insideClass) { return { pattern: result }; } else if (ch === '[') { insideClass = true; result += ch; } else if (ch === ']' && insideClass) { result += ch; insideClass = false; } else { result += ch; } } this._throwError('Unterminated regex'); } private _readStringOrRegex(): string | AriaRegex | null { const ch = this._peek(); if (ch === '"') { this._next(); return normalizeWhitespace(this._readString()); } if (ch === '/') { this._next(); return this._readRegex(); } return null; } private _readAttributes(result: AriaTemplateRoleNode) { let errorPos = this._pos; while (true) { this._skipWhitespace(); if (this._peek() === '[') { this._next(); this._skipWhitespace(); errorPos = this._pos; const flagName = this._readIdentifier('attribute'); this._skipWhitespace(); let flagValue = ''; if (this._peek() === '=') { this._next(); this._skipWhitespace(); errorPos = this._pos; while (this._peek() !== ']' && !this._isWhitespace() && !this._eof()) flagValue += this._next(); } this._skipWhitespace(); if (this._peek() !== ']') this._throwError('Expected ]'); this._next(); // Consume ']' this._applyAttribute(result, flagName, flagValue || 'true', errorPos); } else { break; } } } _parse(): AriaTemplateRoleNode { this._skipWhitespace(); const role = this._readIdentifier('role') as AriaTemplateRoleNode['role']; this._skipWhitespace(); const name = this._readStringOrRegex() || ''; const result: AriaTemplateRoleNode = { kind: 'role', role, name }; this._readAttributes(result); this._skipWhitespace(); if (!this._eof()) this._throwError('Unexpected input'); return result; } private _applyAttribute(node: AriaTemplateRoleNode, key: string, value: string, errorPos: number) { if (key === 'checked') { this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "checked\" attribute must be a boolean or "mixed"', errorPos); node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed'; return; } if (key === 'disabled') { this._assert(value === 'true' || value === 'false', 'Value of "disabled" attribute must be a boolean', errorPos); node.disabled = value === 'true'; return; } if (key === 'expanded') { this._assert(value === 'true' || value === 'false', 'Value of "expanded" attribute must be a boolean', errorPos); node.expanded = value === 'true'; return; } if (key === 'active') { this._assert(value === 'true' || value === 'false', 'Value of "active" attribute must be a boolean', errorPos); node.active = value === 'true'; return; } if (key === 'level') { this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos); node.level = Number(value); return; } if (key === 'pressed') { this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "pressed" attribute must be a boolean or "mixed"', errorPos); node.pressed = value === 'true' ? true : value === 'false' ? false : 'mixed'; return; } if (key === 'selected') { this._assert(value === 'true' || value === 'false', 'Value of "selected" attribute must be a boolean', errorPos); node.selected = value === 'true'; return; } this._assert(false, `Unsupported attribute [${key}]`, errorPos); } private _assert(value: any, message: string, valuePos: number): asserts value { if (!value) this._throwError(message || 'Assertion error', valuePos); } } export class ParserError extends Error { readonly pos: number; constructor(message: string, pos: number) { super(message); this.pos = pos; } } export function findNewNode(from: AriaNode | undefined, to: AriaNode): AriaNode | undefined { type ByRoleAndName = Map>; function fillMap(root: AriaNode, map: ByRoleAndName, position: number) { let size = 1; let childPosition = position + size; for (const child of root.children || []) { if (typeof child === 'string') { size++; childPosition++; } else { size += fillMap(child, map, childPosition); childPosition += size; } } if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) { let byRole = map.get(root.role); if (!byRole) { byRole = new Map(); map.set(root.role, byRole); } const existing = byRole.get(root.name); // This heuristic prioritizes elements at the top of the page, even if somewhat smaller. const sizeAndPosition = size * 100 - position; if (!existing || existing.sizeAndPosition < sizeAndPosition) byRole.set(root.name, { node: root, sizeAndPosition }); } return size; } const fromMap: ByRoleAndName = new Map(); if (from) fillMap(from, fromMap, 0); const toMap: ByRoleAndName = new Map(); fillMap(to, toMap, 0); const result: { node: AriaNode, sizeAndPosition: number }[] = []; for (const [role, byRole] of toMap) { for (const [name, byName] of byRole) { const inFrom = fromMap.get(role)?.get(name); if (!inFrom) result.push(byName); } } result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition); return result[0]?.node; }