diff --git a/package-lock.json b/package-lock.json index 3bc7c072e977b..0a39fa4283dcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", - "yaml": "^2.5.1" + "yaml": "^2.6.0" }, "engines": { "node": ">=18" @@ -7803,10 +7803,10 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", - "dev": true, + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -8101,7 +8101,10 @@ } }, "packages/recorder": { - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "yaml": "^2.6.0" + } }, "packages/trace-viewer": { "version": "0.0.0" diff --git a/package.json b/package.json index 0e14d12e3e065..26dfbc21edd23 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,6 @@ "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", - "yaml": "^2.5.1" + "yaml": "^2.6.0" } } diff --git a/packages/playwright-core/bundles/utils/package-lock.json b/packages/playwright-core/bundles/utils/package-lock.json index e31dbe6209ac1..06ef45a297cec 100644 --- a/packages/playwright-core/bundles/utils/package-lock.json +++ b/packages/playwright-core/bundles/utils/package-lock.json @@ -27,7 +27,7 @@ "socks-proxy-agent": "8.0.4", "stack-utils": "2.0.5", "ws": "8.17.1", - "yaml": "^2.5.1" + "yaml": "^2.6.0" }, "devDependencies": { "@types/debug": "^4.1.7", diff --git a/packages/playwright-core/bundles/utils/package.json b/packages/playwright-core/bundles/utils/package.json index d8f6b7a481252..2e3a92919c18e 100644 --- a/packages/playwright-core/bundles/utils/package.json +++ b/packages/playwright-core/bundles/utils/package.json @@ -28,7 +28,7 @@ "socks-proxy-agent": "8.0.4", "stack-utils": "2.0.5", "ws": "8.17.1", - "yaml": "^2.5.1" + "yaml": "^2.6.0" }, "devDependencies": { "@types/debug": "^4.1.7", diff --git a/packages/playwright-core/src/server/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts index 6a20b2ef82a5b..d2cf79d65301d 100644 --- a/packages/playwright-core/src/server/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/ariaSnapshot.ts @@ -14,250 +14,13 @@ * limitations under the License. */ -import type { AriaTemplateNode, AriaTemplateRoleNode } from './injected/ariaSnapshot'; +import { parseYamlTemplate } from '../utils/isomorphic/ariaSnapshot'; +import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; import { yaml } from '../utilsBundle'; -import { assert } from '../utils'; export function parseAriaSnapshot(text: string): AriaTemplateNode { const fragment = yaml.parse(text); if (!Array.isArray(fragment)) throw new Error('Expected object key starting with "- ":\n\n' + text + '\n'); - const result: AriaTemplateNode = { kind: 'role', role: 'fragment' }; - populateNode(result, fragment); - return result; -} - -function populateNode(node: AriaTemplateRoleNode, container: any[]) { - for (const object of container) { - if (typeof object === 'string') { - const childNode = KeyParser.parse(object); - node.children = node.children || []; - node.children.push(childNode); - continue; - } - - 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) - }); - continue; - } - - const childNode = KeyParser.parse(key); - if (childNode.kind === 'text') { - node.children.push({ - kind: 'text', - text: valueOrRegex(value) - }); - continue; - } - - if (typeof value === 'string') { - node.children.push({ - ...childNode, children: [{ - kind: 'text', - text: valueOrRegex(value) - }] - }); - continue; - } - - node.children.push(childNode); - populateNode(childNode, value); - } - } -} - -function applyAttribute(node: AriaTemplateRoleNode, key: string, value: string) { - if (key === 'checked') { - assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "checked\" attribute must be a boolean or "mixed"'); - node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed'; - return; - } - if (key === 'disabled') { - assert(value === 'true' || value === 'false', 'Value of "disabled" attribute must be a boolean'); - node.disabled = value === 'true'; - return; - } - if (key === 'expanded') { - assert(value === 'true' || value === 'false', 'Value of "expanded" attribute must be a boolean'); - node.expanded = value === 'true'; - return; - } - if (key === 'level') { - assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number'); - node.level = Number(value); - return; - } - if (key === 'pressed') { - assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "pressed" attribute must be a boolean or "mixed"'); - node.pressed = value === 'true' ? true : value === 'false' ? false : 'mixed'; - return; - } - if (key === 'selected') { - assert(value === 'true' || value === 'false', 'Value of "selected" attribute must be a boolean'); - node.selected = value === 'true'; - return; - } - throw new Error(`Unsupported attribute [${key}]`); -} - -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 class KeyParser { - private _input: string; - private _pos: number; - private _length: number; - - static parse(input: string): AriaTemplateNode { - return new KeyParser(input)._parse(); - } - - 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 _skipWhitespace() { - while (!this._eof() && /\s/.test(this._peek())) - this._pos++; - } - - private _readIdentifier(): string { - if (this._eof()) - this._throwError('Unexpected end of input when expecting identifier'); - 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): never { - throw new Error(message + ':\n\n' + this._input + '\n' + ' '.repeat(this._pos) + '^\n'); - } - - private _readRegex(): string { - let result = ''; - let escaped = false; - while (!this._eof()) { - const ch = this._next(); - if (escaped) { - result += ch; - escaped = false; - } else if (ch === '\\') { - escaped = true; - result += ch; - } else if (ch === '/') { - return result; - } else { - result += ch; - } - } - this._throwError('Unterminated regex'); - } - - private _readStringOrRegex(): string | RegExp | null { - const ch = this._peek(); - if (ch === '"') { - this._next(); - return this._readString(); - } - - if (ch === '/') { - this._next(); - return new RegExp(this._readRegex()); - } - - return null; - } - - private _readFlags(): Map { - const flags = new Map(); - while (true) { - this._skipWhitespace(); - if (this._peek() === '[') { - this._next(); - this._skipWhitespace(); - const flagName = this._readIdentifier(); - this._skipWhitespace(); - let flagValue = ''; - if (this._peek() === '=') { - this._next(); - this._skipWhitespace(); - while (this._peek() !== ']' && !this._eof()) - flagValue += this._next(); - } - this._skipWhitespace(); - if (this._peek() !== ']') - this._throwError('Expected ]'); - - this._next(); // Consume ']' - flags.set(flagName, flagValue || 'true'); - } else { - break; - } - } - return flags; - } - - _parse(): AriaTemplateNode { - this._skipWhitespace(); - - const role = this._readIdentifier() as AriaTemplateRoleNode['role']; - this._skipWhitespace(); - const name = this._readStringOrRegex() || ''; - const result: AriaTemplateRoleNode = { kind: 'role', role, name }; - const flags = this._readFlags(); - for (const [name, value] of flags) - applyAttribute(result, name, value); - this._skipWhitespace(); - if (!this._eof()) - this._throwError('Unexpected input'); - return result; - } + return parseYamlTemplate(fragment); } diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 9078459dfb0c7..8aca49e973e7e 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -16,39 +16,17 @@ import * as roleUtils from './roleUtils'; import { getElementComputedStyle } from './domUtils'; -import type { AriaRole } from './roleUtils'; import { escapeRegExp, longestCommonSubstring } from '@isomorphic/stringUtils'; import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml'; - -type AriaProps = { - checked?: boolean | 'mixed'; - disabled?: boolean; - expanded?: boolean; - level?: number; - pressed?: boolean | 'mixed'; - selected?: boolean; -}; +import type { AriaProps, AriaRole, AriaTemplateNode, AriaTemplateRoleNode, AriaTemplateTextNode } from '@isomorphic/ariaSnapshot'; type AriaNode = AriaProps & { role: AriaRole | 'fragment'; name: string; children: (AriaNode | string)[]; + element: Element; }; -export type AriaTemplateTextNode = { - kind: 'text'; - text: RegExp | string; -}; - -export type AriaTemplateRoleNode = AriaProps & { - kind: 'role'; - role: AriaRole | 'fragment'; - name?: RegExp | string; - children?: AriaTemplateNode[]; -}; - -export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode; - export function generateAriaTree(rootElement: Element): AriaNode { const visited = new Set(); const visit = (ariaNode: AriaNode, node: Node) => { @@ -122,7 +100,7 @@ export function generateAriaTree(rootElement: Element): AriaNode { } roleUtils.beginAriaCaches(); - const ariaRoot: AriaNode = { role: 'fragment', name: '', children: [] }; + const ariaRoot: AriaNode = { role: 'fragment', name: '', children: [], element: rootElement }; try { visit(ariaRoot, rootElement); } finally { @@ -139,7 +117,7 @@ function toAriaNode(element: Element): AriaNode | null { return null; const name = roleUtils.getElementAccessibleName(element, false) || ''; - const result: AriaNode = { role, name, children: [] }; + const result: AriaNode = { role, name, children: [], element }; if (roleUtils.kAriaCheckedRoles.includes(role)) result.checked = roleUtils.getAriaChecked(element); @@ -224,7 +202,7 @@ export type MatcherReceived = { regex: string; }; -export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: MatcherReceived } { +export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } { const root = generateAriaTree(rootElement); const matches = matchesNodeDeep(root, template); return { @@ -236,6 +214,11 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode }; } +export function getAllByAria(rootElement: Element, template: AriaTemplateNode): Element[] { + const result = matchesAriaTree(rootElement, template); + return result.matches.map(n => n.element); +} + function matchesNode(node: AriaNode | string, template: AriaTemplateNode, depth: number): boolean { if (typeof node === 'string' && template.kind === 'text') return matchesTextNode(node, template); @@ -282,11 +265,11 @@ function containsList(children: (AriaNode | string)[], template: AriaTemplateNod return true; } -function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean { - const results: (AriaNode | string)[] = []; +function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): AriaNode[] { + const results: AriaNode[] = []; const visit = (node: AriaNode | string): boolean => { if (matchesNode(node, template, 0)) { - results.push(node); + results.push(node as AriaNode); return true; } if (typeof node === 'string') @@ -298,7 +281,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean { return false; }; visit(root); - return !!results.length; + return results; } export function renderAriaTree(ariaNode: AriaNode, options?: { mode?: 'raw' | 'regex' }): string { diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 6f0d8ee473626..d74a1c1482eb5 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -34,7 +34,9 @@ import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } fr import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; -import { matchesAriaTree, renderedAriaTree } from './ariaSnapshot'; +import { matchesAriaTree, renderedAriaTree, getAllByAria } from './ariaSnapshot'; +import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; +import { parseYamlTemplate } from '@isomorphic/ariaSnapshot'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -82,6 +84,7 @@ export class InjectedScript { isElementVisible, isInsideScope, normalizeWhiteSpace, + parseYamlTemplate, }; // eslint-disable-next-line no-restricted-globals @@ -218,6 +221,10 @@ export class InjectedScript { return renderedAriaTree(node as Element, options); } + getAllByAria(document: Document, template: AriaTemplateNode): Element[] { + return getAllByAria(document.documentElement, template); + } + querySelectorAll(selector: ParsedSelector, root: Node): Element[] { if (selector.capture !== undefined) { if (selector.parts.some(part => part.name === 'nth')) @@ -1263,8 +1270,13 @@ export class InjectedScript { } { - if (expression === 'to.match.aria') - return matchesAriaTree(element, options.expectedValue); + if (expression === 'to.match.aria') { + const result = matchesAriaTree(element, options.expectedValue); + return { + received: result.received, + matches: !!result.matches.length, + }; + } } { diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 389ec04276d16..3bf9eeebf581c 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -207,9 +207,9 @@ class InspectTool implements RecorderTool { class RecordActionTool implements RecorderTool { private _recorder: Recorder; private _performingActions = new Set(); - private _hoveredModel: HighlightModel | null = null; + private _hoveredModel: HighlightModeWithSelector | null = null; private _hoveredElement: HTMLElement | null = null; - private _activeModel: HighlightModel | null = null; + private _activeModel: HighlightModeWithSelector | null = null; private _expectProgrammaticKeyUp = false; private _pendingClickAction: { action: actions.ClickAction, timeout: number } | undefined; @@ -605,7 +605,7 @@ class RecordActionTool implements RecorderTool { class TextAssertionTool implements RecorderTool { private _recorder: Recorder; - private _hoverHighlight: HighlightModel | null = null; + private _hoverHighlight: HighlightModeWithSelector | null = null; private _action: actions.AssertAction | null = null; private _dialog: Dialog; private _textCache = new Map(); @@ -1019,6 +1019,7 @@ export class Recorder { private _currentTool: RecorderTool; private _tools: Record; private _actionSelectorModel: HighlightModel | null = null; + private _lastHighlightedAriaTemplateJSON: string = 'undefined'; readonly highlight: Highlight; readonly overlay: Overlay | undefined; private _stylesheet: CSSStyleSheet; @@ -1129,10 +1130,26 @@ export class Recorder { this.overlay?.setUIState(state); // Race or scroll. - if (this._actionSelectorModel?.selector && !this._actionSelectorModel?.elements.length) + if (this._actionSelectorModel?.selector && !this._actionSelectorModel?.elements.length && !this._lastHighlightedAriaTemplateJSON) + this._actionSelectorModel = null; + + if (state.actionSelector && state.actionSelector !== this._actionSelectorModel?.selector) + this._actionSelectorModel = querySelector(this.injectedScript, state.actionSelector, this.document); + + 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) : []; + if (elements.length) + this._actionSelectorModel = { elements }; + else + this._actionSelectorModel = null; + } + + if (!state.actionSelector && !state.ariaTemplate) this._actionSelectorModel = null; - if (state.actionSelector !== this._actionSelectorModel?.selector) - this._actionSelectorModel = state.actionSelector ? querySelector(this.injectedScript, state.actionSelector, this.document) : null; + if (this.state.mode === 'none' || this.state.mode === 'standby') this.updateHighlight(this._actionSelectorModel, false); } @@ -1439,10 +1456,14 @@ function consumeEvent(e: Event) { } type HighlightModel = HighlightOptions & { - selector: string; + selector?: string; elements: Element[]; }; +type HighlightModeWithSelector = HighlightModel & { + selector: string; +}; + function asCheckbox(node: Node | null): HTMLInputElement | null { if (!node || node.nodeName !== 'INPUT') return null; diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index df52329b1ead4..50564fa31f163 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { AriaRole } from '@isomorphic/ariaSnapshot'; import { closestCrossShadow, elementSafeTagName, enclosingShadowRootOrDocument, getElementComputedStyle, isElementStyleVisibilityVisible, isVisibleTextNode, parentElementOrShadowHost } from './domUtils'; function hasExplicitAccessibleName(e: Element) { @@ -211,18 +212,6 @@ function getImplicitAriaRole(element: Element): AriaRole | null { return implicitRole; } -// https://www.w3.org/TR/wai-aria-1.2/#role_definitions -// https://www.w3.org/TR/wai-aria-1.2/#abstract_roles -// type AbstractRoles = 'command' | 'composite' | 'input' | 'landmark' | 'range' | 'roletype' | 'section' | 'sectionhead' | 'select' | 'structure' | 'widget' | 'window'; - -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'; - const validRoles: 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', diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 8b026c789ab84..028939d2a466b 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -32,6 +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'; const recorderSymbol = Symbol('recorderSymbol'); @@ -39,7 +40,7 @@ export class Recorder implements InstrumentationListener, IRecorder { readonly handleSIGINT: boolean | undefined; private _context: BrowserContext; private _mode: Mode; - private _highlightedSelector = ''; + private _highlightedElement: { selector?: string, ariaSnapshot?: ParsedYaml } = {}; private _overlayState: OverlayState = { offsetX: 0 }; private _recorderApp: IRecorderApp | null = null; private _currentCallsMetadata = new Map(); @@ -103,8 +104,11 @@ export class Recorder implements InstrumentationListener, IRecorder { this.setMode(data.params.mode); return; } - if (data.event === 'selectorUpdated') { - this.setHighlightedSelector(this._currentLanguage, data.params.selector); + if (data.event === 'highlightRequested') { + if (data.params.selector) + this.setHighlightedSelector(this._currentLanguage, data.params.selector); + if (data.params.ariaSnapshot) + this.setHighlightedAriaSnapshot(data.params.ariaSnapshot); return; } if (data.event === 'step') { @@ -149,7 +153,7 @@ export class Recorder implements InstrumentationListener, IRecorder { }); await this._context.exposeBinding('__pw_recorderState', false, async source => { - let actionSelector = ''; + let actionSelector: string | undefined; let actionPoint: Point | undefined; const hasActiveScreenshotCommand = [...this._currentCallsMetadata.keys()].some(isScreenshotCommand); if (!hasActiveScreenshotCommand) { @@ -165,6 +169,7 @@ export class Recorder implements InstrumentationListener, IRecorder { mode: this._mode, actionPoint, actionSelector, + ariaTemplate: this._highlightedElement.ariaSnapshot, language: this._currentLanguage, testIdAttributeName: this._contextRecorder.testIdAttributeName(), overlay: this._overlayState, @@ -217,7 +222,7 @@ export class Recorder implements InstrumentationListener, IRecorder { setMode(mode: Mode) { if (this._mode === mode) return; - this._highlightedSelector = ''; + this._highlightedElement = {}; this._mode = mode; this._recorderApp?.setMode(this._mode); this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue' || this._mode === 'assertingSnapshot'); @@ -236,19 +241,26 @@ export class Recorder implements InstrumentationListener, IRecorder { } setHighlightedSelector(language: Language, selector: string) { - this._highlightedSelector = locatorOrSelectorAsSelector(language, selector, this._context.selectors().testIdAttributeName()); + this._highlightedElement = { selector: locatorOrSelectorAsSelector(language, selector, this._context.selectors().testIdAttributeName()) }; + this._refreshOverlay(); + } + + setHighlightedAriaSnapshot(ariaSnapshot: ParsedYaml) { + this._highlightedElement = { ariaSnapshot }; this._refreshOverlay(); } hideHighlightedSelector() { - this._highlightedSelector = ''; + this._highlightedElement = {}; this._refreshOverlay(); } - private async _scopeHighlightedSelectorToFrame(frame: Frame): Promise { + private async _scopeHighlightedSelectorToFrame(frame: Frame): Promise { + if (!this._highlightedElement.selector) + return; try { const mainFrame = frame._page.mainFrame(); - const resolved = await mainFrame.selectors.resolveFrameForSelector(this._highlightedSelector); + const resolved = await mainFrame.selectors.resolveFrameForSelector(this._highlightedElement.selector); // selector couldn't be found, don't highlight anything if (!resolved) return ''; @@ -288,7 +300,7 @@ export class Recorder implements InstrumentationListener, IRecorder { if (isScreenshotCommand(metadata)) this.hideHighlightedSelector(); else if (metadata.params && metadata.params.selector) - this._highlightedSelector = metadata.params.selector; + this._highlightedElement = { selector: metadata.params.selector }; } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { diff --git a/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts new file mode 100644 index 0000000000000..be7cd134f6eab --- /dev/null +++ b/packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts @@ -0,0 +1,311 @@ +/** + * 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'; + +export type ParsedYaml = Array; + +export type AriaProps = { + checked?: boolean | 'mixed'; + disabled?: boolean; + expanded?: boolean; + level?: number; + pressed?: boolean | 'mixed'; + selected?: boolean; +}; + +export type AriaTemplateTextNode = { + kind: 'text'; + text: RegExp | string; +}; + +export type AriaTemplateRoleNode = AriaProps & { + kind: 'role'; + role: AriaRole | 'fragment'; + name?: RegExp | string; + children?: AriaTemplateNode[]; +}; + +export type AriaTemplateNode = AriaTemplateRoleNode | AriaTemplateTextNode; + +export function parseYamlTemplate(fragment: ParsedYaml): AriaTemplateNode { + const result: AriaTemplateNode = { kind: 'role', role: 'fragment' }; + populateNode(result, fragment); + return result; +} + +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; + } + + 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) + }); + continue; + } + + const childNode = KeyParser.parse(key); + if (childNode.kind === 'text') { + node.children.push({ + kind: 'text', + text: valueOrRegex(value) + }); + continue; + } + + if (typeof value === 'string') { + node.children.push({ + ...childNode, children: [{ + kind: 'text', + text: valueOrRegex(value) + }] + }); + continue; + } + + node.children.push(childNode); + populateNode(childNode, value); + } + } +} + +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); +} + +class KeyParser { + private _input: string; + private _pos: number; + private _length: number; + + static parse(input: string): AriaTemplateNode { + return new KeyParser(input)._parse(); + } + + 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 _skipWhitespace() { + while (!this._eof() && /\s/.test(this._peek())) + this._pos++; + } + + private _readIdentifier(): string { + if (this._eof()) + this._throwError('Unexpected end of input when expecting identifier'); + 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, pos?: number): never { + throw new AriaKeyError(message, this._input, pos || this._pos); + } + + private _readRegex(): string { + let result = ''; + let escaped = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === '\\') { + escaped = true; + result += ch; + } else if (ch === '/') { + return result; + } else { + result += ch; + } + } + this._throwError('Unterminated regex'); + } + + private _readStringOrRegex(): string | RegExp | null { + const ch = this._peek(); + if (ch === '"') { + this._next(); + return this._readString(); + } + + if (ch === '/') { + this._next(); + return new RegExp(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(); + this._skipWhitespace(); + let flagValue = ''; + if (this._peek() === '=') { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + while (this._peek() !== ']' && !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(): AriaTemplateNode { + this._skipWhitespace(); + + const role = this._readIdentifier() 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 === '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 function parseAriaKey(key: string) { + return KeyParser.parse(key); +} + +export class AriaKeyError extends Error { + readonly shortMessage: string; + readonly pos: number; + + constructor(message: string, input: string, pos: number) { + super(message + ':\n\n' + input + '\n' + ' '.repeat(pos) + '^\n'); + this.shortMessage = message; + this.pos = pos; + this.stack = undefined; + } +} diff --git a/packages/recorder/package.json b/packages/recorder/package.json index 8482a7a7db77f..0bad170fee760 100644 --- a/packages/recorder/package.json +++ b/packages/recorder/package.json @@ -7,5 +7,8 @@ "dev": "vite", "build": "vite build && tsc", "preview": "vite preview" + }, + "dependencies": { + "yaml": "^2.6.0" } } diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 2b606843635c3..6488883b7db0f 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -16,6 +16,7 @@ import type { CallLog, ElementInfo, Mode, Source } from './recorderTypes'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; +import type { SourceHighlight } from '@web/components/codeMirrorWrapper'; import { SplitView } from '@web/components/splitView'; import { TabbedPane } from '@web/components/tabbedPane'; import { Toolbar } from '@web/components/toolbar'; @@ -27,6 +28,10 @@ import './recorder.css'; import { asLocator } from '@isomorphic/locatorGenerators'; import { toggleTheme } from '@web/theme'; import { copy } from '@web/uiUtils'; +import yaml from 'yaml'; +import type { YAMLError } from 'yaml'; +import { parseAriaKey } from '@isomorphic/ariaSnapshot'; +import type { AriaKeyError, ParsedYaml } from '@isomorphic/ariaSnapshot'; export interface RecorderProps { sources: Source[], @@ -45,6 +50,7 @@ export const Recorder: React.FC = ({ const [runningFileId, setRunningFileId] = React.useState(); const [selectedTab, setSelectedTab] = React.useState('log'); const [ariaSnapshot, setAriaSnapshot] = React.useState(); + const [ariaSnapshotErrors, setAriaSnapshotErrors] = React.useState(); const fileId = selectedFileId || runningFileId || sources[0]?.id; @@ -105,7 +111,17 @@ export const Recorder: React.FC = ({ if (mode === 'none' || mode === 'inspecting') window.dispatch({ event: 'setMode', params: { mode: 'standby' } }); setLocator(selector); - window.dispatch({ event: 'selectorUpdated', params: { selector } }); + window.dispatch({ event: 'highlightRequested', params: { selector } }); + }, [mode]); + + 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); + setAriaSnapshot(ariaSnapshot); + if (!errors.length) + window.dispatch({ event: 'highlightRequested', params: { ariaSnapshot: fragment } }); }, [mode]); return
@@ -183,7 +199,7 @@ export const Recorder: React.FC = ({ { id: 'aria', title: 'Aria snapshot', - render: () => + render: () => }, ]} selectedTab={selectedTab} @@ -192,3 +208,56 @@ export const Recorder: React.FC = ({ />
; }; + +function parseAriaSnapshot(ariaSnapshot: string): { fragment?: ParsedYaml, errors: SourceHighlight[] } { + const lineCounter = new yaml.LineCounter(); + let yamlDoc: yaml.Document; + try { + yamlDoc = yaml.parseDocument(ariaSnapshot, { + keepSourceTokens: true, + lineCounter, + }); + } catch (e) { + const error = e as YAMLError; + const pos = error.linePos?.[0]; + return { + errors: [{ + line: pos?.line || 0, + type: 'error', + message: error.message, + }], + }; + } + + const errors: SourceHighlight[] = []; + const handleKey = (key: yaml.Scalar) => { + try { + parseAriaKey(key.value); + } catch (e) { + const keyError = e as AriaKeyError; + errors.push({ + message: keyError.message, + line: lineCounter.linePos(key.srcToken!.offset + keyError.pos).line, + type: '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.ts b/packages/recorder/src/recorderTypes.ts index 22afde3dadd1a..4822dda46f2bd 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -15,6 +15,7 @@ */ import type { Language } from '../../playwright-core/src/utils/isomorphic/locatorGenerators'; +import type { ParsedYaml } from '@isomorphic/ariaSnapshot'; export type Point = { x: number; y: number }; @@ -41,7 +42,7 @@ export type EventData = { | 'step' | 'pause' | 'setMode' - | 'selectorUpdated' + | 'highlightRequested' | 'fileChanged'; params: any; }; @@ -54,6 +55,7 @@ export type UIState = { mode: Mode; actionPoint?: Point; actionSelector?: string; + ariaTemplate?: ParsedYaml; language: Language; testIdAttributeName: string; overlay: OverlayState; diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx index c9bc6565e887d..3234bf7f26676 100644 --- a/packages/web/src/components/codeMirrorWrapper.tsx +++ b/packages/web/src/components/codeMirrorWrapper.tsx @@ -26,7 +26,7 @@ export type SourceHighlight = { message?: string; }; -export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css' | 'markdown'; +export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css' | 'markdown' | 'yaml'; export const lineHeight = 20; @@ -236,5 +236,6 @@ function languageToMode(language: Language | undefined): string | undefined { markdown: 'markdown', html: 'htmlmixed', css: 'css', + yaml: 'yaml', }[language]; } diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index 754f3fd2a9e62..0cc9a72c462ea 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -528,35 +528,55 @@ heading /title const error = await expect(page.locator('body')).toMatchAriaSnapshot(` - heading [level=a] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "level" attribute must be a number`); + expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "level" attribute must be a number: + +heading [level=a] + ^ +`); } { const error = await expect(page.locator('body')).toMatchAriaSnapshot(` - heading [expanded=FALSE] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "expanded" attribute must be a boolean`); + expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "expanded" attribute must be a boolean: + +heading [expanded=FALSE] + ^ +`); } { const error = await expect(page.locator('body')).toMatchAriaSnapshot(` - heading [checked=foo] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "checked" attribute must be a boolean or "mixed"`); + expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "checked" attribute must be a boolean or "mixed": + +heading [checked=foo] + ^ +`); } { const error = await expect(page.locator('body')).toMatchAriaSnapshot(` - heading [level=] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "level" attribute must be a number`); + expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Value of "level" attribute must be a number: + +heading [level=] + ^ +`); } { const error = await expect(page.locator('body')).toMatchAriaSnapshot(` - heading [bogus] `).catch(e => e); - expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Unsupported attribute [bogus]`); + expect.soft(error.message).toBe(`expect.toMatchAriaSnapshot: Unsupported attribute [bogus]: + +heading [bogus] + ^ +`); } {