chore: consolidate aria parser in isomorphic bundle (#34298)

This commit is contained in:
Pavel Feldman 2025-01-10 15:32:35 -08:00 committed by GitHub
parent 4bb464197f
commit 0c8a6b80fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 203 additions and 173 deletions

View File

@ -1,30 +0,0 @@
/**
* 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 { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
import type { AriaTemplateNode, ParsedYaml } from '@isomorphic/ariaSnapshot';
import { yaml } from '../utilsBundle';
export function parseAriaSnapshot(text: string): AriaTemplateNode {
return parseYamlTemplate(parseYamlForAriaSnapshot(text));
}
export function parseYamlForAriaSnapshot(text: string): ParsedYaml {
const parsed = yaml.parse(text);
if (!Array.isArray(parsed))
throw new Error('Expected object key starting with "- ":\n\n' + text + '\n');
return parsed;
}

View File

@ -24,10 +24,9 @@ import type { Playwright } from './playwright';
import { Recorder } from './recorder';
import { EmptyRecorderApp } from './recorder/recorderApp';
import { asLocator, type Language } from '../utils';
import { parseYamlForAriaSnapshot } from './ariaSnapshot';
import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot';
import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
import { yaml } from '../utilsBundle';
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot';
const internalMetadata = serverSideCallMetadata();
@ -125,14 +124,10 @@ export class DebugController extends SdkObject {
// Assert parameters validity.
if (params.selector)
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
let parsedYaml: ParsedYaml | undefined;
if (params.ariaTemplate) {
parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate);
parseYamlTemplate(parsedYaml);
}
const ariaTemplate = params.ariaTemplate ? parseAriaSnapshotUnsafe(yaml, params.ariaTemplate) : undefined;
for (const recorder of await this._allRecorders()) {
if (parsedYaml)
recorder.setHighlightedAriaTemplate(parsedYaml);
if (ariaTemplate)
recorder.setHighlightedAriaTemplate(ariaTemplate);
else if (params.selector)
recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
}

View File

@ -3,5 +3,7 @@
../../generated/
../../protocol/
../../utils/
../../utils/isomorphic
../../utilsBundle.ts
../../zipBundle.ts
../**

View File

@ -26,7 +26,8 @@ import type { CallMetadata } from '../instrumentation';
import type { BrowserContextDispatcher } from './browserContextDispatcher';
import type { PageDispatcher } from './pageDispatcher';
import { debugAssert } from '../../utils';
import { parseAriaSnapshot } from '../ariaSnapshot';
import { parseAriaSnapshotUnsafe } from '../../utils/isomorphic/ariaSnapshot';
import { yaml } from '../../utilsBundle';
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, BrowserContextDispatcher | PageDispatcher> implements channels.FrameChannel {
_type_Frame = true;
@ -261,7 +262,7 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
metadata.potentiallyClosesScope = true;
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
if (params.expression === 'to.match.aria' && expectedValue)
expectedValue = parseAriaSnapshot(expectedValue);
expectedValue = parseAriaSnapshotUnsafe(yaml, expectedValue);
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
if (result.received !== undefined)
result.received = serializeResult(result.received);

View File

@ -18,7 +18,7 @@ import * as roleUtils from './roleUtils';
import { getElementComputedStyle } from './domUtils';
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
import type { AriaProps, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot';
import type { AriaProps, AriaRegex, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot';
export type AriaNode = AriaProps & {
role: AriaRole | 'fragment';
@ -196,14 +196,14 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
visit(rootA11yNode);
}
function matchesText(text: string, template: RegExp | string | undefined): boolean {
function matchesText(text: string, template: AriaRegex | string | undefined): boolean {
if (!template)
return true;
if (!text)
return false;
if (typeof template === 'string')
return text === template;
return !!text.match(template);
return !!text.match(new RegExp(template.pattern));
}
function matchesTextNode(text: string, template: AriaTemplateTextNode) {

View File

@ -37,7 +37,7 @@ import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis
import { matchesAriaTree, getAllByAria, generateAriaTree, renderAriaTree } from './ariaSnapshot';
import type { AriaNode, AriaSnapshot } from './ariaSnapshot';
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
import { parseYamlTemplate } from '@isomorphic/ariaSnapshot';
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
@ -86,7 +86,7 @@ export class InjectedScript {
isElementVisible,
isInsideScope,
normalizeWhiteSpace,
parseYamlTemplate,
parseAriaSnapshot,
};
// eslint-disable-next-line no-restricted-globals

View File

@ -1146,8 +1146,7 @@ export class Recorder {
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
const template = state.ariaTemplate ? this.injectedScript.utils.parseYamlTemplate(state.ariaTemplate) : undefined;
const elements = template ? this.injectedScript.getAllByAria(this.document, template) : [];
const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
if (elements.length)
highlight = { elements };
else

View File

@ -32,7 +32,7 @@ import type * as actions from '@recorder/actions';
import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
import { stringifySelector } from '../utils/isomorphic/selectorParser';
import type { Frame } from './frames';
import type { ParsedYaml } from '@isomorphic/ariaSnapshot';
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
const recorderSymbol = Symbol('recorderSymbol');
@ -40,7 +40,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
readonly handleSIGINT: boolean | undefined;
private _context: BrowserContext;
private _mode: Mode;
private _highlightedElement: { selector?: string, ariaTemplate?: ParsedYaml } = {};
private _highlightedElement: { selector?: string, ariaTemplate?: AriaTemplateNode } = {};
private _overlayState: OverlayState = { offsetX: 0 };
private _recorderApp: IRecorderApp | null = null;
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
@ -249,7 +249,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
this._refreshOverlay();
}
setHighlightedAriaTemplate(ariaTemplate: ParsedYaml) {
setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) {
this._highlightedElement = { ariaTemplate };
this._refreshOverlay();
}

View File

@ -24,8 +24,6 @@ export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'ba
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
export type ParsedYaml = Array<any>;
export type AriaProps = {
checked?: boolean | 'mixed';
disabled?: boolean;
@ -35,89 +33,209 @@ export type AriaProps = {
selected?: boolean;
};
// We pass parsed template between worlds using JSON, make it easy.
export type AriaRegex = { pattern: string };
export type AriaTemplateTextNode = {
kind: 'text';
text: RegExp | string;
text: AriaRegex | string;
};
export type AriaTemplateRoleNode = AriaProps & {
kind: 'role';
role: AriaRole | 'fragment';
name?: RegExp | string;
name?: AriaRegex | string;
children?: AriaTemplateNode[];
};
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
export function parseYamlTemplate(fragment: ParsedYaml): AriaTemplateNode {
const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
populateNode(result, fragment);
if (result.children && result.children.length === 1)
return result.children[0];
return result;
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; };
export type ParsedYamlError = {
message: string;
range: [ParsedYamlPosition, ParsedYamlPosition];
};
export function parseAriaSnapshotUnsafe(yaml: YamlLibrary, text: string): AriaTemplateNode {
const result = parseAriaSnapshot(yaml, text);
if (result.errors.length)
throw new Error(result.errors[0].message);
return result.fragment;
}
function populateNode(node: AriaTemplateRoleNode, container: ParsedYaml) {
for (const object of container) {
if (typeof object === 'string') {
const childNode = KeyParser.parse(object);
node.children = node.children || [];
node.children.push(childNode);
continue;
export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yamlTypes.ParseOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } {
const lineCounter = new yaml.LineCounter();
const parseOptions: yamlTypes.ParseOptions = {
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),
});
}
};
for (const key of Object.keys(object)) {
node.children = node.children || [];
const value = object[key];
if (key === 'text') {
node.children.push({
kind: 'text',
text: valueOrRegex(value)
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 childNode = KeyParser.parse(key);
if (childNode.kind === 'text') {
node.children.push({
const key: yamlTypes.Scalar<string> = entry.key as yamlTypes.Scalar<string>;
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: valueOrRegex(value)
text: valueOrRegex(value.value)
});
continue;
}
if (typeof value === 'string') {
node.children.push({
...childNode, children: [{
// role "name": ...
const childNode = KeyParser.parse(key, parseOptions, errors);
if (!childNode)
continue;
// - role "name": "text"
const valueIsScalar = value instanceof yaml.Scalar;
if (valueIsScalar) {
container.children.push({
...childNode,
children: [{
kind: 'text',
text: valueOrRegex(value)
text: valueOrRegex(String(value.value))
}]
});
continue;
}
node.children.push(childNode);
populateNode(childNode, value);
// - role "name":
// - child
const valueIsSequence = value instanceof yaml.YAMLSeq ;
if (valueIsSequence) {
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: convertRange(yamlDoc.contents!.range),
});
}
if (errors.length)
return { errors, fragment };
convertSeq(fragment, yamlDoc.contents as yamlTypes.YAMLSeq);
if (errors.length)
return { errors, fragment: emptyFragment };
if (fragment.children?.length === 1)
return { fragment: fragment.children[0], errors };
return { fragment, errors };
}
const emptyFragment: AriaTemplateRoleNode = { kind: 'role', role: 'fragment' };
function normalizeWhitespace(text: string) {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
}
function valueOrRegex(value: string): string | RegExp {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
export function valueOrRegex(value: string): string | AriaRegex {
return value.startsWith('/') && value.endsWith('/') ? { pattern: value.slice(1, -1) } : normalizeWhitespace(value);
}
class KeyParser {
export class KeyParser {
private _input: string;
private _pos: number;
private _length: number;
static parse(input: string): AriaTemplateNode {
return new KeyParser(input)._parse();
static parse(text: yamlTypes.Scalar<string>, options: yamlTypes.ParseOptions, 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) {
@ -177,11 +295,11 @@ class KeyParser {
this._throwError('Unterminated string');
}
private _throwError(message: string, pos?: number): never {
throw new AriaKeyError(message, this._input, pos || this._pos);
private _throwError(message: string, offset: number = 0): never {
throw new ParserError(message, offset || this._pos);
}
private _readRegex(): string {
private _readRegex(): AriaRegex {
let result = '';
let escaped = false;
let insideClass = false;
@ -194,7 +312,7 @@ class KeyParser {
escaped = true;
result += ch;
} else if (ch === '/' && !insideClass) {
return result;
return { pattern: result };
} else if (ch === '[') {
insideClass = true;
result += ch;
@ -208,7 +326,7 @@ class KeyParser {
this._throwError('Unterminated regex');
}
private _readStringOrRegex(): string | RegExp | null {
private _readStringOrRegex(): string | AriaRegex | null {
const ch = this._peek();
if (ch === '"') {
this._next();
@ -217,7 +335,7 @@ class KeyParser {
if (ch === '/') {
this._next();
return new RegExp(this._readRegex());
return this._readRegex();
}
return null;
@ -253,7 +371,7 @@ class KeyParser {
}
}
_parse(): AriaTemplateNode {
_parse(): AriaTemplateRoleNode {
this._skipWhitespace();
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
@ -307,18 +425,11 @@ class KeyParser {
}
}
export function parseAriaKey(key: string) {
return KeyParser.parse(key);
}
export class AriaKeyError extends Error {
readonly shortMessage: string;
export class ParserError extends Error {
readonly pos: number;
constructor(message: string, input: string, pos: number) {
super(message + ':\n\n' + input + '\n' + ' '.repeat(pos) + '^\n');
this.shortMessage = message;
constructor(message: string, pos: number) {
super(message);
this.pos = pos;
this.stack = undefined;
}
}

View File

@ -33,6 +33,7 @@ export const program: typeof import('../bundles/utils/node_modules/commander').p
export const progress: typeof import('../bundles/utils/node_modules/@types/progress') = require('./utilsBundleImpl').progress;
export const SocksProxyAgent: typeof import('../bundles/utils/node_modules/socks-proxy-agent').SocksProxyAgent = require('./utilsBundleImpl').SocksProxyAgent;
export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml;
export type { Scalar as YAMLScalar, YAMLSeq, YAMLMap, YAMLError, Range as YAMLRange } from '../bundles/utils/node_modules/yaml';
export const ws: typeof import('../bundles/utils/node_modules/@types/ws') = require('./utilsBundleImpl').ws;
export const wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer;
export const wsReceiver = require('./utilsBundleImpl').wsReceiver;

View File

@ -29,8 +29,7 @@ import { asLocator } from '@isomorphic/locatorGenerators';
import { toggleTheme } from '@web/theme';
import { copy, useSetting } from '@web/uiUtils';
import yaml from 'yaml';
import { parseAriaKey } from '@isomorphic/ariaSnapshot';
import type { AriaKeyError, ParsedYaml } from '@isomorphic/ariaSnapshot';
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
export interface RecorderProps {
sources: Source[],
@ -117,8 +116,17 @@ export const Recorder: React.FC<RecorderProps> = ({
const onAriaEditorChange = React.useCallback((ariaSnapshot: string) => {
if (mode === 'none' || mode === 'inspecting')
window.dispatch({ event: 'setMode', params: { mode: 'standby' } });
const { fragment, errors } = parseAriaSnapshot(ariaSnapshot);
setAriaSnapshotErrors(errors);
const { fragment, errors } = parseAriaSnapshot(yaml, ariaSnapshot, { prettyErrors: false });
const highlights = errors.map(error => {
const highlight: SourceHighlight = {
message: error.message,
line: error.range[1].line,
column: error.range[1].col,
type: 'subtle-error',
};
return highlight;
});
setAriaSnapshotErrors(highlights);
setAriaSnapshot(ariaSnapshot);
if (!errors.length)
window.dispatch({ event: 'highlightRequested', params: { ariaTemplate: fragment } });
@ -208,57 +216,3 @@ export const Recorder: React.FC<RecorderProps> = ({
/>
</div>;
};
function parseAriaSnapshot(ariaSnapshot: string): { fragment?: ParsedYaml, errors: SourceHighlight[] } {
const lineCounter = new yaml.LineCounter();
const yamlDoc = yaml.parseDocument(ariaSnapshot, {
keepSourceTokens: true,
lineCounter,
prettyErrors: false,
});
const errors: SourceHighlight[] = [];
for (const error of yamlDoc.errors) {
errors.push({
line: lineCounter.linePos(error.pos[0]).line,
type: 'subtle-error',
message: error.message,
});
}
if (yamlDoc.errors.length)
return { errors };
const handleKey = (key: yaml.Scalar<string>) => {
try {
parseAriaKey(key.value);
} catch (e) {
const keyError = e as AriaKeyError;
const linePos = lineCounter.linePos(key.srcToken!.offset + keyError.pos);
errors.push({
message: keyError.shortMessage,
line: linePos.line,
column: linePos.col,
type: 'subtle-error',
});
}
};
const visitSeq = (seq: yaml.YAMLSeq) => {
for (const item of seq.items) {
if (item instanceof yaml.YAMLMap) {
const map = item as yaml.YAMLMap;
for (const entry of map.items) {
if (entry.key instanceof yaml.Scalar)
handleKey(entry.key);
if (entry.value instanceof yaml.YAMLSeq)
visitSeq(entry.value);
}
continue;
}
if (item instanceof yaml.Scalar)
handleKey(item);
}
};
visitSeq(yamlDoc.contents as yaml.YAMLSeq);
return errors.length ? { errors } : { fragment: yamlDoc.toJSON(), errors };
}

View File

@ -15,7 +15,7 @@
*/
import type { Language } from '../../playwright-core/src/utils/isomorphic/locatorGenerators';
import type { ParsedYaml } from '@isomorphic/ariaSnapshot';
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
export type Point = { x: number; y: number };
@ -55,7 +55,7 @@ export type UIState = {
mode: Mode;
actionPoint?: Point;
actionSelector?: string;
ariaTemplate?: ParsedYaml;
ariaTemplate?: AriaTemplateNode;
language: Language;
testIdAttributeName: string;
overlay: OverlayState;

View File

@ -521,10 +521,7 @@ test('should report error in YAML', async ({ page }) => {
const error = await expect(page.locator('body')).toMatchAriaSnapshot(`
heading "title"
`).catch(e => e);
expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Expected object key starting with "- ":
heading "title"
`);
expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Aria snapshot must be a YAML sequence, elements starting with " -"`);
}
{