diff --git a/packages/playwright-core/src/server/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts index 6a20b2ef82a5b..423858a837c73 100644 --- a/packages/playwright-core/src/server/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/ariaSnapshot.ts @@ -14,250 +14,12 @@ * limitations under the License. */ -import type { AriaTemplateNode, AriaTemplateRoleNode } from './injected/ariaSnapshot'; +import type { ParsedYaml } from '@injected/ariaSnapshot'; import { yaml } from '../utilsBundle'; -import { assert } from '../utils'; -export function parseAriaSnapshot(text: string): AriaTemplateNode { +export function parseYamlForAriaSnapshot(text: string): ParsedYaml { 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 fragment; } diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 2f172df694f34..a4d3799260375 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -26,7 +26,7 @@ import type { CallMetadata } from '../instrumentation'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; import type { PageDispatcher } from './pageDispatcher'; import { debugAssert } from '../../utils'; -import { parseAriaSnapshot } from '../ariaSnapshot'; +import { parseYamlForAriaSnapshot } from '../ariaSnapshot'; export class FrameDispatcher extends Dispatcher implements channels.FrameChannel { _type_Frame = true; @@ -261,7 +261,7 @@ export class FrameDispatcher extends Dispatcher; + type AriaProps = { checked?: boolean | 'mixed'; disabled?: boolean; @@ -33,6 +35,7 @@ type AriaNode = AriaProps & { role: AriaRole | 'fragment'; name: string; children: (AriaNode | string)[]; + element: Element; }; export type AriaTemplateTextNode = { @@ -122,7 +125,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 +142,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 +227,7 @@ export type MatcherReceived = { regex: string; }; -export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: MatcherReceived } { +export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { received: MatcherReceived, matches: AriaNode[] } { const root = generateAriaTree(rootElement); const matches = matchesNodeDeep(root, template); return { @@ -232,10 +235,15 @@ export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode received: { raw: renderAriaTree(root, { mode: 'raw' }), regex: renderAriaTree(root, { mode: 'regex' }), - } + }, }; } +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 +290,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 +306,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 { @@ -423,3 +431,245 @@ function textContributesInfo(node: AriaNode, text: string): boolean { filtered = filtered.replace(substr, ''); return filtered.trim().length / text.length > 0.1; } + +function yamlValueToStringOrRegex(value: string): string | RegExp { + return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value.replace(/[\r\n\s\t]+/g, ' ').trim(); +} + +export function parseYamlTemplate(fragment: ParsedYaml): AriaTemplateNode { + const result: AriaTemplateNode = { kind: 'role', role: 'fragment' }; + populateYamlTemplate(result, fragment); + return result; +} + +function populateYamlTemplate(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: yamlValueToStringOrRegex(value) + }); + continue; + } + + const childNode = KeyParser.parse(key); + if (childNode.kind === 'text') { + node.children.push({ + kind: 'text', + text: yamlValueToStringOrRegex(value) + }); + continue; + } + + if (typeof value === 'string') { + node.children.push({ + ...childNode, children: [{ + kind: 'text', + text: yamlValueToStringOrRegex(value) + }] + }); + continue; + } + + node.children.push(childNode); + populateYamlTemplate(childNode, 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): 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) + this._applyAttribute(result, name, value); + this._skipWhitespace(); + if (!this._eof()) + this._throwError('Unexpected input'); + return result; + } + + private _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}]`); + } +} + +export function assert(value: any, message?: string): asserts value { + if (!value) + throw new Error(message || 'Assertion error'); +} diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 6f0d8ee473626..a5960389c2ffb 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -34,7 +34,8 @@ 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, parseYamlTemplate } from './ariaSnapshot'; +import type { AriaTemplateNode, ParsedYaml } from './ariaSnapshot'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -82,6 +83,7 @@ export class InjectedScript { isElementVisible, isInsideScope, normalizeWhiteSpace, + parseYamlTemplate, }; // eslint-disable-next-line no-restricted-globals @@ -218,6 +220,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 +1269,14 @@ export class InjectedScript { } { - if (expression === 'to.match.aria') - return matchesAriaTree(element, options.expectedValue); + if (expression === 'to.match.aria') { + const fragment = options.expectedValue as ParsedYaml; + const result = matchesAriaTree(element, parseYamlTemplate(fragment)); + 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..5d1ada5cd3835 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -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,28 @@ 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 !== this._actionSelectorModel?.selector) - this._actionSelectorModel = state.actionSelector ? querySelector(this.injectedScript, state.actionSelector, this.document) : 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) { + const generated = this.injectedScript.generateSelector(elements[0], { testIdAttributeName: this.state.testIdAttributeName }); + this._actionSelectorModel = { selector: generated.selector, elements: generated.elements }; + } else { + this._actionSelectorModel = null; + } + } + + if (!state.actionSelector && !state.ariaTemplate) + this._actionSelectorModel = null; + if (this.state.mode === 'none' || this.state.mode === 'standby') this.updateHighlight(this._actionSelectorModel, false); } diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 8b026c789ab84..70c5d7ec4580f 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 { parseYamlForAriaSnapshot } from './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?: string } = {}; 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 ? parseYamlForAriaSnapshot(this._highlightedElement.ariaSnapshot) : undefined, 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: string) { + 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/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 2b606843635c3..0b713016577c8 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -105,7 +105,14 @@ 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' } }); + setAriaSnapshot(ariaSnapshot); + window.dispatch({ event: 'highlightRequested', params: { ariaSnapshot } }); }, [mode]); return
@@ -183,7 +190,7 @@ export const Recorder: React.FC = ({ { id: 'aria', title: 'Aria snapshot', - render: () => + render: () => }, ]} selectedTab={selectedTab} diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index 22afde3dadd1a..fc9cfa2bc418f 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 '../../playwright-core/src/server/injected/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]; }