diff --git a/packages/playwright-core/src/server/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts deleted file mode 100644 index 516688fef3..0000000000 --- a/packages/playwright-core/src/server/ariaSnapshot.ts +++ /dev/null @@ -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; -} diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index 176b6d2734..468b550f48 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -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); } diff --git a/packages/playwright-core/src/server/dispatchers/DEPS.list b/packages/playwright-core/src/server/dispatchers/DEPS.list index de1039af05..cefc3fa04c 100644 --- a/packages/playwright-core/src/server/dispatchers/DEPS.list +++ b/packages/playwright-core/src/server/dispatchers/DEPS.list @@ -3,5 +3,7 @@ ../../generated/ ../../protocol/ ../../utils/ +../../utils/isomorphic +../../utilsBundle.ts ../../zipBundle.ts ../** diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 2f172df694..3426389a9c 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -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 implements channels.FrameChannel { _type_Frame = true; @@ -261,7 +262,7 @@ export class FrameDispatcher extends Dispatcher & { expectedValue?: any }; @@ -86,7 +86,7 @@ export class InjectedScript { isElementVisible, isInsideScope, normalizeWhiteSpace, - parseYamlTemplate, + parseAriaSnapshot, }; // eslint-disable-next-line no-restricted-globals diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index b24feec5d1..5a3f3d9a14 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -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 diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 16f9d791e1..115639223c 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -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(); @@ -249,7 +249,7 @@ export class Recorder implements InstrumentationListener, IRecorder { this._refreshOverlay(); } - setHighlightedAriaTemplate(ariaTemplate: ParsedYaml) { + setHighlightedAriaTemplate(ariaTemplate: AriaTemplateNode) { this._highlightedElement = { ariaTemplate }; this._refreshOverlay(); } diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts index 59b26ec049..b04add5be9 100644 --- a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -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; - 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 = 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: 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, 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; } } diff --git a/packages/playwright-core/src/utilsBundle.ts b/packages/playwright-core/src/utilsBundle.ts index 72bcee397e..fba52c05a8 100644 --- a/packages/playwright-core/src/utilsBundle.ts +++ b/packages/playwright-core/src/utilsBundle.ts @@ -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; diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index a34131a2c8..bf0ead8ce2 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -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 = ({ 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 = ({ /> ; }; - -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) => { - 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 }; -} diff --git a/packages/recorder/src/recorderTypes.d.ts b/packages/recorder/src/recorderTypes.d.ts index 4822dda46f..22561608ae 100644 --- a/packages/recorder/src/recorderTypes.d.ts +++ b/packages/recorder/src/recorderTypes.d.ts @@ -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; diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 3961d6ad0a..ac94909741 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -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 " -"`); } {