chore: consolidate aria parser in isomorphic bundle (#34298)
This commit is contained in:
parent
4bb464197f
commit
0c8a6b80fb
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -24,10 +24,9 @@ import type { Playwright } from './playwright';
|
||||||
import { Recorder } from './recorder';
|
import { Recorder } from './recorder';
|
||||||
import { EmptyRecorderApp } from './recorder/recorderApp';
|
import { EmptyRecorderApp } from './recorder/recorderApp';
|
||||||
import { asLocator, type Language } from '../utils';
|
import { asLocator, type Language } from '../utils';
|
||||||
import { parseYamlForAriaSnapshot } from './ariaSnapshot';
|
import { yaml } from '../utilsBundle';
|
||||||
import type { ParsedYaml } from '../utils/isomorphic/ariaSnapshot';
|
|
||||||
import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot';
|
|
||||||
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
import { unsafeLocatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
||||||
|
import { parseAriaSnapshotUnsafe } from '../utils/isomorphic/ariaSnapshot';
|
||||||
|
|
||||||
const internalMetadata = serverSideCallMetadata();
|
const internalMetadata = serverSideCallMetadata();
|
||||||
|
|
||||||
|
|
@ -125,14 +124,10 @@ export class DebugController extends SdkObject {
|
||||||
// Assert parameters validity.
|
// Assert parameters validity.
|
||||||
if (params.selector)
|
if (params.selector)
|
||||||
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
|
unsafeLocatorOrSelectorAsSelector(this._sdkLanguage, params.selector, 'data-testid');
|
||||||
let parsedYaml: ParsedYaml | undefined;
|
const ariaTemplate = params.ariaTemplate ? parseAriaSnapshotUnsafe(yaml, params.ariaTemplate) : undefined;
|
||||||
if (params.ariaTemplate) {
|
|
||||||
parsedYaml = parseYamlForAriaSnapshot(params.ariaTemplate);
|
|
||||||
parseYamlTemplate(parsedYaml);
|
|
||||||
}
|
|
||||||
for (const recorder of await this._allRecorders()) {
|
for (const recorder of await this._allRecorders()) {
|
||||||
if (parsedYaml)
|
if (ariaTemplate)
|
||||||
recorder.setHighlightedAriaTemplate(parsedYaml);
|
recorder.setHighlightedAriaTemplate(ariaTemplate);
|
||||||
else if (params.selector)
|
else if (params.selector)
|
||||||
recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
|
recorder.setHighlightedSelector(this._sdkLanguage, params.selector);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,7 @@
|
||||||
../../generated/
|
../../generated/
|
||||||
../../protocol/
|
../../protocol/
|
||||||
../../utils/
|
../../utils/
|
||||||
|
../../utils/isomorphic
|
||||||
|
../../utilsBundle.ts
|
||||||
../../zipBundle.ts
|
../../zipBundle.ts
|
||||||
../**
|
../**
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ import type { CallMetadata } from '../instrumentation';
|
||||||
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
import type { BrowserContextDispatcher } from './browserContextDispatcher';
|
||||||
import type { PageDispatcher } from './pageDispatcher';
|
import type { PageDispatcher } from './pageDispatcher';
|
||||||
import { debugAssert } from '../../utils';
|
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 {
|
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, BrowserContextDispatcher | PageDispatcher> implements channels.FrameChannel {
|
||||||
_type_Frame = true;
|
_type_Frame = true;
|
||||||
|
|
@ -261,7 +262,7 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
|
||||||
metadata.potentiallyClosesScope = true;
|
metadata.potentiallyClosesScope = true;
|
||||||
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
|
let expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
|
||||||
if (params.expression === 'to.match.aria' && expectedValue)
|
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 });
|
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
|
||||||
if (result.received !== undefined)
|
if (result.received !== undefined)
|
||||||
result.received = serializeResult(result.received);
|
result.received = serializeResult(result.received);
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import * as roleUtils from './roleUtils';
|
||||||
import { getElementComputedStyle } from './domUtils';
|
import { getElementComputedStyle } from './domUtils';
|
||||||
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace } from '@isomorphic/stringUtils';
|
||||||
import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
|
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 & {
|
export type AriaNode = AriaProps & {
|
||||||
role: AriaRole | 'fragment';
|
role: AriaRole | 'fragment';
|
||||||
|
|
@ -196,14 +196,14 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
|
||||||
visit(rootA11yNode);
|
visit(rootA11yNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesText(text: string, template: RegExp | string | undefined): boolean {
|
function matchesText(text: string, template: AriaRegex | string | undefined): boolean {
|
||||||
if (!template)
|
if (!template)
|
||||||
return true;
|
return true;
|
||||||
if (!text)
|
if (!text)
|
||||||
return false;
|
return false;
|
||||||
if (typeof template === 'string')
|
if (typeof template === 'string')
|
||||||
return text === template;
|
return text === template;
|
||||||
return !!text.match(template);
|
return !!text.match(new RegExp(template.pattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesTextNode(text: string, template: AriaTemplateTextNode) {
|
function matchesTextNode(text: string, template: AriaTemplateTextNode) {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis
|
||||||
import { matchesAriaTree, getAllByAria, generateAriaTree, renderAriaTree } from './ariaSnapshot';
|
import { matchesAriaTree, getAllByAria, generateAriaTree, renderAriaTree } from './ariaSnapshot';
|
||||||
import type { AriaNode, AriaSnapshot } from './ariaSnapshot';
|
import type { AriaNode, AriaSnapshot } from './ariaSnapshot';
|
||||||
import type { AriaTemplateNode } from '@isomorphic/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 };
|
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ export class InjectedScript {
|
||||||
isElementVisible,
|
isElementVisible,
|
||||||
isInsideScope,
|
isInsideScope,
|
||||||
normalizeWhiteSpace,
|
normalizeWhiteSpace,
|
||||||
parseYamlTemplate,
|
parseAriaSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
|
|
||||||
|
|
@ -1146,8 +1146,7 @@ export class Recorder {
|
||||||
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
|
const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
|
||||||
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
|
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
|
||||||
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
|
this._lastHighlightedAriaTemplateJSON = ariaTemplateJSON;
|
||||||
const template = state.ariaTemplate ? this.injectedScript.utils.parseYamlTemplate(state.ariaTemplate) : undefined;
|
const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
|
||||||
const elements = template ? this.injectedScript.getAllByAria(this.document, template) : [];
|
|
||||||
if (elements.length)
|
if (elements.length)
|
||||||
highlight = { elements };
|
highlight = { elements };
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import type * as actions from '@recorder/actions';
|
||||||
import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
|
import { buildFullSelector } from '../utils/isomorphic/recorderUtils';
|
||||||
import { stringifySelector } from '../utils/isomorphic/selectorParser';
|
import { stringifySelector } from '../utils/isomorphic/selectorParser';
|
||||||
import type { Frame } from './frames';
|
import type { Frame } from './frames';
|
||||||
import type { ParsedYaml } from '@isomorphic/ariaSnapshot';
|
import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot';
|
||||||
|
|
||||||
const recorderSymbol = Symbol('recorderSymbol');
|
const recorderSymbol = Symbol('recorderSymbol');
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
||||||
readonly handleSIGINT: boolean | undefined;
|
readonly handleSIGINT: boolean | undefined;
|
||||||
private _context: BrowserContext;
|
private _context: BrowserContext;
|
||||||
private _mode: Mode;
|
private _mode: Mode;
|
||||||
private _highlightedElement: { selector?: string, ariaTemplate?: ParsedYaml } = {};
|
private _highlightedElement: { selector?: string, ariaTemplate?: AriaTemplateNode } = {};
|
||||||
private _overlayState: OverlayState = { offsetX: 0 };
|
private _overlayState: OverlayState = { offsetX: 0 };
|
||||||
private _recorderApp: IRecorderApp | null = null;
|
private _recorderApp: IRecorderApp | null = null;
|
||||||
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
||||||
|
|
@ -249,7 +249,7 @@ export class Recorder implements InstrumentationListener, IRecorder {
|
||||||
this._refreshOverlay();
|
this._refreshOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
setHighlightedAriaTemplate(ariaTemplate: ParsedYaml) {
|
setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) {
|
||||||
this._highlightedElement = { ariaTemplate };
|
this._highlightedElement = { ariaTemplate };
|
||||||
this._refreshOverlay();
|
this._refreshOverlay();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' |
|
'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
|
||||||
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
|
'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem';
|
||||||
|
|
||||||
export type ParsedYaml = Array<any>;
|
|
||||||
|
|
||||||
export type AriaProps = {
|
export type AriaProps = {
|
||||||
checked?: boolean | 'mixed';
|
checked?: boolean | 'mixed';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -35,89 +33,209 @@ export type AriaProps = {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// We pass parsed template between worlds using JSON, make it easy.
|
||||||
|
export type AriaRegex = { pattern: string };
|
||||||
|
|
||||||
export type AriaTemplateTextNode = {
|
export type AriaTemplateTextNode = {
|
||||||
kind: 'text';
|
kind: 'text';
|
||||||
text: RegExp | string;
|
text: AriaRegex | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AriaTemplateRoleNode = AriaProps & {
|
export type AriaTemplateRoleNode = AriaProps & {
|
||||||
kind: 'role';
|
kind: 'role';
|
||||||
role: AriaRole | 'fragment';
|
role: AriaRole | 'fragment';
|
||||||
name?: RegExp | string;
|
name?: AriaRegex | string;
|
||||||
children?: AriaTemplateNode[];
|
children?: AriaTemplateNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
|
export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode;
|
||||||
|
|
||||||
export function parseYamlTemplate(fragment: ParsedYaml): AriaTemplateNode {
|
import type * as yamlTypes from 'yaml';
|
||||||
const result: AriaTemplateNode = { kind: 'role', role: 'fragment' };
|
|
||||||
populateNode(result, fragment);
|
type YamlLibrary = {
|
||||||
if (result.children && result.children.length === 1)
|
parseDocument: typeof yamlTypes.parseDocument;
|
||||||
return result.children[0];
|
Scalar: typeof yamlTypes.Scalar;
|
||||||
return result;
|
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) {
|
export function parseAriaSnapshot(yaml: YamlLibrary, text: string, options: yamlTypes.ParseOptions = {}): { fragment: AriaTemplateNode, errors: ParsedYamlError[] } {
|
||||||
for (const object of container) {
|
const lineCounter = new yaml.LineCounter();
|
||||||
if (typeof object === 'string') {
|
const parseOptions: yamlTypes.ParseOptions = {
|
||||||
const childNode = KeyParser.parse(object);
|
keepSourceTokens: true,
|
||||||
node.children = node.children || [];
|
lineCounter,
|
||||||
node.children.push(childNode);
|
...options,
|
||||||
continue;
|
};
|
||||||
|
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)) {
|
const convertMap = (container: AriaTemplateRoleNode, map: yamlTypes.YAMLMap) => {
|
||||||
node.children = node.children || [];
|
for (const entry of map.items) {
|
||||||
const value = object[key];
|
container.children = container.children || [];
|
||||||
|
// Key must by a string
|
||||||
if (key === 'text') {
|
const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === 'string';
|
||||||
node.children.push({
|
if (!keyIsString) {
|
||||||
kind: 'text',
|
errors.push({
|
||||||
text: valueOrRegex(value)
|
message: 'Only string keys are supported',
|
||||||
|
range: convertRange((entry.key as any).range || map.range),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const childNode = KeyParser.parse(key);
|
const key: yamlTypes.Scalar<string> = entry.key as yamlTypes.Scalar<string>;
|
||||||
if (childNode.kind === 'text') {
|
const value = entry.value;
|
||||||
node.children.push({
|
|
||||||
|
// - 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',
|
kind: 'text',
|
||||||
text: valueOrRegex(value)
|
text: valueOrRegex(value.value)
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
// role "name": ...
|
||||||
node.children.push({
|
const childNode = KeyParser.parse(key, parseOptions, errors);
|
||||||
...childNode, children: [{
|
if (!childNode)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// - role "name": "text"
|
||||||
|
const valueIsScalar = value instanceof yaml.Scalar;
|
||||||
|
if (valueIsScalar) {
|
||||||
|
container.children.push({
|
||||||
|
...childNode,
|
||||||
|
children: [{
|
||||||
kind: 'text',
|
kind: 'text',
|
||||||
text: valueOrRegex(value)
|
text: valueOrRegex(String(value.value))
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.children.push(childNode);
|
// - role "name":
|
||||||
populateNode(childNode, value);
|
// - 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) {
|
function normalizeWhitespace(text: string) {
|
||||||
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
|
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueOrRegex(value: string): string | RegExp {
|
export function valueOrRegex(value: string): string | AriaRegex {
|
||||||
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
|
return value.startsWith('/') && value.endsWith('/') ? { pattern: value.slice(1, -1) } : normalizeWhitespace(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
class KeyParser {
|
export class KeyParser {
|
||||||
private _input: string;
|
private _input: string;
|
||||||
private _pos: number;
|
private _pos: number;
|
||||||
private _length: number;
|
private _length: number;
|
||||||
|
|
||||||
static parse(input: string): AriaTemplateNode {
|
static parse(text: yamlTypes.Scalar<string>, options: yamlTypes.ParseOptions, errors: ParsedYamlError[]): AriaTemplateRoleNode | null {
|
||||||
return new KeyParser(input)._parse();
|
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) {
|
constructor(input: string) {
|
||||||
|
|
@ -177,11 +295,11 @@ class KeyParser {
|
||||||
this._throwError('Unterminated string');
|
this._throwError('Unterminated string');
|
||||||
}
|
}
|
||||||
|
|
||||||
private _throwError(message: string, pos?: number): never {
|
private _throwError(message: string, offset: number = 0): never {
|
||||||
throw new AriaKeyError(message, this._input, pos || this._pos);
|
throw new ParserError(message, offset || this._pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _readRegex(): string {
|
private _readRegex(): AriaRegex {
|
||||||
let result = '';
|
let result = '';
|
||||||
let escaped = false;
|
let escaped = false;
|
||||||
let insideClass = false;
|
let insideClass = false;
|
||||||
|
|
@ -194,7 +312,7 @@ class KeyParser {
|
||||||
escaped = true;
|
escaped = true;
|
||||||
result += ch;
|
result += ch;
|
||||||
} else if (ch === '/' && !insideClass) {
|
} else if (ch === '/' && !insideClass) {
|
||||||
return result;
|
return { pattern: result };
|
||||||
} else if (ch === '[') {
|
} else if (ch === '[') {
|
||||||
insideClass = true;
|
insideClass = true;
|
||||||
result += ch;
|
result += ch;
|
||||||
|
|
@ -208,7 +326,7 @@ class KeyParser {
|
||||||
this._throwError('Unterminated regex');
|
this._throwError('Unterminated regex');
|
||||||
}
|
}
|
||||||
|
|
||||||
private _readStringOrRegex(): string | RegExp | null {
|
private _readStringOrRegex(): string | AriaRegex | null {
|
||||||
const ch = this._peek();
|
const ch = this._peek();
|
||||||
if (ch === '"') {
|
if (ch === '"') {
|
||||||
this._next();
|
this._next();
|
||||||
|
|
@ -217,7 +335,7 @@ class KeyParser {
|
||||||
|
|
||||||
if (ch === '/') {
|
if (ch === '/') {
|
||||||
this._next();
|
this._next();
|
||||||
return new RegExp(this._readRegex());
|
return this._readRegex();
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -253,7 +371,7 @@ class KeyParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_parse(): AriaTemplateNode {
|
_parse(): AriaTemplateRoleNode {
|
||||||
this._skipWhitespace();
|
this._skipWhitespace();
|
||||||
|
|
||||||
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
|
const role = this._readIdentifier('role') as AriaTemplateRoleNode['role'];
|
||||||
|
|
@ -307,18 +425,11 @@ class KeyParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAriaKey(key: string) {
|
export class ParserError extends Error {
|
||||||
return KeyParser.parse(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AriaKeyError extends Error {
|
|
||||||
readonly shortMessage: string;
|
|
||||||
readonly pos: number;
|
readonly pos: number;
|
||||||
|
|
||||||
constructor(message: string, input: string, pos: number) {
|
constructor(message: string, pos: number) {
|
||||||
super(message + ':\n\n' + input + '\n' + ' '.repeat(pos) + '^\n');
|
super(message);
|
||||||
this.shortMessage = message;
|
|
||||||
this.pos = pos;
|
this.pos = pos;
|
||||||
this.stack = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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 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 wsServer: typeof import('../bundles/utils/node_modules/@types/ws').WebSocketServer = require('./utilsBundleImpl').wsServer;
|
||||||
export const wsReceiver = require('./utilsBundleImpl').wsReceiver;
|
export const wsReceiver = require('./utilsBundleImpl').wsReceiver;
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,7 @@ import { asLocator } from '@isomorphic/locatorGenerators';
|
||||||
import { toggleTheme } from '@web/theme';
|
import { toggleTheme } from '@web/theme';
|
||||||
import { copy, useSetting } from '@web/uiUtils';
|
import { copy, useSetting } from '@web/uiUtils';
|
||||||
import yaml from 'yaml';
|
import yaml from 'yaml';
|
||||||
import { parseAriaKey } from '@isomorphic/ariaSnapshot';
|
import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot';
|
||||||
import type { AriaKeyError, ParsedYaml } from '@isomorphic/ariaSnapshot';
|
|
||||||
|
|
||||||
export interface RecorderProps {
|
export interface RecorderProps {
|
||||||
sources: Source[],
|
sources: Source[],
|
||||||
|
|
@ -117,8 +116,17 @@ export const Recorder: React.FC<RecorderProps> = ({
|
||||||
const onAriaEditorChange = React.useCallback((ariaSnapshot: string) => {
|
const onAriaEditorChange = React.useCallback((ariaSnapshot: string) => {
|
||||||
if (mode === 'none' || mode === 'inspecting')
|
if (mode === 'none' || mode === 'inspecting')
|
||||||
window.dispatch({ event: 'setMode', params: { mode: 'standby' } });
|
window.dispatch({ event: 'setMode', params: { mode: 'standby' } });
|
||||||
const { fragment, errors } = parseAriaSnapshot(ariaSnapshot);
|
const { fragment, errors } = parseAriaSnapshot(yaml, ariaSnapshot, { prettyErrors: false });
|
||||||
setAriaSnapshotErrors(errors);
|
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);
|
setAriaSnapshot(ariaSnapshot);
|
||||||
if (!errors.length)
|
if (!errors.length)
|
||||||
window.dispatch({ event: 'highlightRequested', params: { ariaTemplate: fragment } });
|
window.dispatch({ event: 'highlightRequested', params: { ariaTemplate: fragment } });
|
||||||
|
|
@ -208,57 +216,3 @@ export const Recorder: React.FC<RecorderProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>;
|
</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 };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
4
packages/recorder/src/recorderTypes.d.ts
vendored
4
packages/recorder/src/recorderTypes.d.ts
vendored
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Language } from '../../playwright-core/src/utils/isomorphic/locatorGenerators';
|
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 };
|
export type Point = { x: number; y: number };
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ export type UIState = {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
actionPoint?: Point;
|
actionPoint?: Point;
|
||||||
actionSelector?: string;
|
actionSelector?: string;
|
||||||
ariaTemplate?: ParsedYaml;
|
ariaTemplate?: AriaTemplateNode;
|
||||||
language: Language;
|
language: Language;
|
||||||
testIdAttributeName: string;
|
testIdAttributeName: string;
|
||||||
overlay: OverlayState;
|
overlay: OverlayState;
|
||||||
|
|
|
||||||
|
|
@ -521,10 +521,7 @@ test('should report error in YAML', async ({ page }) => {
|
||||||
const error = await expect(page.locator('body')).toMatchAriaSnapshot(`
|
const error = await expect(page.locator('body')).toMatchAriaSnapshot(`
|
||||||
heading "title"
|
heading "title"
|
||||||
`).catch(e => e);
|
`).catch(e => e);
|
||||||
expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Expected object key starting with "- ":
|
expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Aria snapshot must be a YAML sequence, elements starting with " -"`);
|
||||||
|
|
||||||
heading "title"
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue