diff --git a/packages/injected/src/ariaSnapshot.ts b/packages/injected/src/ariaSnapshot.ts index c72d3c64918f8..610d8b0ea25ce 100644 --- a/packages/injected/src/ariaSnapshot.ts +++ b/packages/injected/src/ariaSnapshot.ts @@ -18,12 +18,13 @@ import * as aria from '@isomorphic/ariaSnapshot'; import { escapeRegExp, longestCommonSubstring, normalizeWhiteSpace, truncateDataUrl } from '@isomorphic/stringUtils'; import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from '@isomorphic/yaml'; +import { distillAriaSnapshot } from './ariaSnapshotDistiller'; import { computeBox, getElementComputedStyle, isElementVisible } from './domUtils'; import * as roleUtils from './roleUtils'; export type AriaSnapshot = { root: aria.AriaNode; - elements: Map; + info: Map; refs: Map; iframeRefs: string[]; }; @@ -84,10 +85,12 @@ function toInternalOptions(options: AriaTreeOptions): InternalOptions { export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOptions): AriaSnapshot { const options = toInternalOptions(publicOptions); const visited = new Set(); + // For each node, the elements that contributed to its accessible name. + const nameSourceElements = new Map | undefined>(); const snapshot: AriaSnapshot = { root: { role: 'fragment', name: '', children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true }, - elements: new Map(), + info: new Map(), refs: new Map(), iframeRefs: [], }; @@ -135,10 +138,12 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp } } - const childAriaNode = visible ? toAriaNode(element, options) : null; + const childAriaNode = visible ? toAriaNode(element, options, nameSourceElements) : null; + let elementInfo: { element: Element, nameFromContentRefs: string[] } | undefined; if (childAriaNode) { if (childAriaNode.ref) { - snapshot.elements.set(childAriaNode.ref, element); + elementInfo = { element, nameFromContentRefs: [] }; + snapshot.info.set(childAriaNode.ref, elementInfo); snapshot.refs.set(element, childAriaNode.ref); if (childAriaNode.role === 'iframe') snapshot.iframeRefs.push(childAriaNode.ref); @@ -146,6 +151,16 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp ariaNode.children.push(childAriaNode); } processElement(childAriaNode || ariaNode, element, ariaChildren, visible); + + // Now that the subtree is processed, every descendant that contributed to this node's + // accessible name has its ref assigned, so we can resolve those refs as the name's origins. + if (elementInfo) { + for (const contributor of nameSourceElements.get(childAriaNode!) || []) { + const ref = snapshot.refs.get(contributor); + if (ref && ref !== childAriaNode!.ref) + elementInfo.nameFromContentRefs.push(ref); + } + } }; function processElement(ariaNode: aria.AriaNode, element: Element, ariaChildren: Element[], parentElementVisible: boolean) { @@ -200,8 +215,7 @@ export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOp roleUtils.endAriaCaches(); } - normalizeStringChildren(snapshot.root); - normalizeGenericRoles(snapshot.root); + distillAriaSnapshot(snapshot, publicOptions); return snapshot; } @@ -220,8 +234,8 @@ function computeAriaRef(ariaNode: aria.AriaNode, options: InternalOptions) { ariaNode.ref = ariaRef.ref; } -function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | null { - const active = element.ownerDocument.activeElement === element; +function toAriaNode(element: Element, options: InternalOptions, nameSourceElements: Map | undefined>): aria.AriaNode | null { + const active = element.ownerDocument.activeElement === element && element.ownerDocument.hasFocus(); if (element.nodeName === 'IFRAME') { const ariaNode: aria.AriaNode = { role: 'iframe', @@ -242,7 +256,7 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | if (!role || role === 'presentation' || role === 'none') return null; - const name = normalizeWhiteSpace(roleUtils.getElementAccessibleName(element, false) || ''); + const name = roleUtils.getElementAccessibleName(element, false); const receivesPointerEvents = roleUtils.receivesPointerEvents(element); const box = computeBox(element); @@ -251,7 +265,7 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | const result: aria.AriaNode = { role, - name, + name: normalizeWhiteSpace(name.text), children: [], props: {}, box, @@ -259,6 +273,7 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | active }; setAriaNodeElement(result, element); + nameSourceElements.set(result, name.elements); computeAriaRef(result, options); if (roleUtils.kAriaCheckedRoles.includes(role)) @@ -292,59 +307,6 @@ function toAriaNode(element: Element, options: InternalOptions): aria.AriaNode | return result; } -function normalizeGenericRoles(node: aria.AriaNode) { - const normalizeChildren = (node: aria.AriaNode) => { - const result: (aria.AriaNode | string)[] = []; - for (const child of node.children || []) { - if (typeof child === 'string') { - result.push(child); - continue; - } - const normalized = normalizeChildren(child); - result.push(...normalized); - } - - // Only remove generic that encloses one element, logical grouping still makes sense, even if it is not ref-able. - const removeSelf = node.role === 'generic' && !node.name && result.length <= 1 && result.every(c => typeof c !== 'string' && !!c.ref); - if (removeSelf) - return result; - node.children = result; - return [node]; - }; - - normalizeChildren(node); -} - -function normalizeStringChildren(rootA11yNode: aria.AriaNode) { - const flushChildren = (buffer: string[], normalizedChildren: (aria.AriaNode | string)[]) => { - if (!buffer.length) - return; - const text = normalizeWhiteSpace(buffer.join('')); - if (text) - normalizedChildren.push(text); - buffer.length = 0; - }; - - const visit = (ariaNode: aria.AriaNode) => { - const normalizedChildren: (aria.AriaNode | string)[] = []; - const buffer: string[] = []; - for (const child of ariaNode.children || []) { - if (typeof child === 'string') { - buffer.push(child); - } else { - flushChildren(buffer, normalizedChildren); - visit(child); - normalizedChildren.push(child); - } - } - flushChildren(buffer, normalizedChildren); - ariaNode.children = normalizedChildren.length ? normalizedChildren : []; - if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) - ariaNode.children = []; - }; - visit(rootA11yNode); -} - function matchesStringOrRegex(text: string, template: aria.AriaRegex | string | undefined): boolean { if (!template) return true; @@ -500,86 +462,11 @@ function matchesNodeDeep(root: aria.AriaNode, template: aria.AriaTemplateNode, c return results; } -function buildByRefMap(root: aria.AriaNode | undefined, map: Map = new Map()): Map { - if (root?.ref) - map.set(root.ref, root); - for (const child of root?.children || []) { - if (typeof child !== 'string') - buildByRefMap(child, map); - } - return map; -} - -function compareSnapshots(ariaSnapshot: AriaSnapshot, previousSnapshot: AriaSnapshot | undefined): Map { - const previousByRef = buildByRefMap(previousSnapshot?.root); - const result = new Map(); - - // Returns whether ariaNode is the same as previousNode. - const visit = (ariaNode: aria.AriaNode, previousNode: aria.AriaNode | undefined): boolean => { - let same: boolean = ariaNode.children.length === previousNode?.children.length && aria.ariaNodesEqual(ariaNode, previousNode); - let canBeSkipped = same; - - for (let childIndex = 0 ; childIndex < ariaNode.children.length; childIndex++) { - const child = ariaNode.children[childIndex]; - const previousChild = previousNode?.children[childIndex]; - if (typeof child === 'string') { - same &&= child === previousChild; - canBeSkipped &&= child === previousChild; - } else { - let previous = typeof previousChild !== 'string' ? previousChild : undefined; - if (child.ref) - previous = previousByRef.get(child.ref); - const sameChild = visit(child, previous); - // New child, different order of children, or changed child with no ref - - // we have to include this node to list children in the right order. - if (!previous || (!sameChild && !child.ref) || (previous !== previousChild)) - canBeSkipped = false; - same &&= (sameChild && previous === previousChild); - } - } - - result.set(ariaNode, same ? 'same' : (canBeSkipped ? 'skip' : 'changed')); - return same; - }; - - visit(ariaSnapshot.root, previousByRef.get(previousSnapshot?.root?.ref)); - return result; -} - -// Chooses only the changed parts of the snapshot and returns them as new roots. -function filterSnapshotDiff(nodes: (aria.AriaNode | string)[], statusMap: Map): (aria.AriaNode | string)[] { - const result: (aria.AriaNode | string)[] = []; - - const visit = (ariaNode: aria.AriaNode) => { - const status = statusMap.get(ariaNode); - if (status === 'same') { - // No need to render unchanged root at all. - } else if (status === 'skip') { - // Only render changed children. - for (const child of ariaNode.children) { - if (typeof child !== 'string') - visit(child); - } - } else { - // Render this node's subtree. - result.push(ariaNode); - } - }; - - for (const node of nodes) { - if (typeof node === 'string') - result.push(node); - else - visit(node); - } - return result; -} - function indent(depth: number): string { return ' '.repeat(depth); } -export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions, previousSnapshot?: AriaSnapshot): { text: string, iframeDepths: Record } { +export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): { text: string, iframeDepths: Record } { const options = toInternalOptions(publicOptions); const lines: string[] = []; const iframeDepths: Record = {}; @@ -587,11 +474,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str; // Do not render the root fragment, just its children. - let nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root]; - - const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot); - if (previousSnapshot) - nodesToRender = filterSnapshotDiff(nodesToRender, statusMap); + const nodesToRender = ariaSnapshot.root.role === 'fragment' ? ariaSnapshot.root.children : [ariaSnapshot.root]; const visitText = (text: string, depth: number) => { if (publicOptions.depth && depth > publicOptions.depth) @@ -649,8 +532,8 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr return key; }; - const getSingleInlinedTextChild = (ariaNode: aria.AriaNode | undefined): string | undefined => { - return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined; + const getSingleTextChild = (ariaNode: aria.AriaNode): string | undefined => { + return ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined; }; const visit = (ariaNode: aria.AriaNode, depth: number, renderCursorPointer: boolean) => { @@ -660,27 +543,19 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr if (ariaNode.role === 'iframe' && ariaNode.ref) iframeDepths[ariaNode.ref] = depth; - // Replace the whole subtree with a single reference when possible. - if (statusMap.get(ariaNode) === 'same' && ariaNode.ref) { - lines.push(indent(depth) + `- ref=${ariaNode.ref} [unchanged]`); - return; - } - - // When producing a diff, add marker to all diff roots. - const isDiffRoot = !!previousSnapshot && !depth; - const escapedKey = indent(depth) + '- ' + (isDiffRoot ? ' ' : '') + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer)); - const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); + const escapedKey = indent(depth) + '- ' + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer)); + const singleTextChild = getSingleTextChild(ariaNode); const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth; - const hasNoChildren = !singleInlinedTextChild && (!ariaNode.children.length || isAtDepthLimit); + const hasNoChildren = !singleTextChild && (!ariaNode.children.length || isAtDepthLimit); if (hasNoChildren && !Object.keys(ariaNode.props).length) { // Leaf node without children. lines.push(escapedKey); - } else if (singleInlinedTextChild !== undefined) { + } else if (singleTextChild !== undefined) { // Leaf node with just some text inside. - const shouldInclude = includeText(ariaNode, singleInlinedTextChild); + const shouldInclude = includeText(ariaNode, singleTextChild); if (shouldInclude) - lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild))); + lines.push(escapedKey + ': ' + yamlEscapeValueIfNeeded(renderString(singleTextChild))); else lines.push(escapedKey); } else { diff --git a/packages/injected/src/ariaSnapshotDistiller.ts b/packages/injected/src/ariaSnapshotDistiller.ts new file mode 100644 index 0000000000000..3481b06b92984 --- /dev/null +++ b/packages/injected/src/ariaSnapshotDistiller.ts @@ -0,0 +1,248 @@ +/** + * 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 { normalizeWhiteSpace } from '@isomorphic/stringUtils'; + +import type * as aria from '@isomorphic/ariaSnapshot'; +import type { AriaSnapshot, AriaTreeOptions } from './ariaSnapshot'; + +// Distillation makes the snapshot less verbose without losing information: after the full tree is +// built, a single traversal applies the chained plugins below, babel-style. Each plugin is a +// visitor: `enter` runs pre-order, `exit` runs post-order after the children were traversed - and +// possibly removed, unwrapped or inlined. Either hook can detach the node by returning 'remove' +// (from `enter`, the subtree is then not traversed and no further hooks run for it), or replace +// the node with its children by returning 'unwrap' (from `enter`, the hoisted children are +// re-visited in the node's place; from `exit`, they were already traversed and are spliced in as +// is). Plugins mutate the tree in place; `snapshot.info` and `snapshot.refs` are left intact, so +// refs of removed nodes still resolve through the aria-ref selector engine. +type DistillerContext = { + snapshot: AriaSnapshot; + // Depth of the current node; children of the root fragment are at depth 0. + depth: number; + // Render depth limit, plugins should not rely on anything below it being rendered. + maxDepth: number | undefined; + // The chain of ancestors of the current node, root first. Maintained by the traversal. + ancestors: aria.AriaNode[]; + // Content refs of the entered nodes' accessible names that are not yet represented in the + // output - see `removeRedundantNames`. + pendingContentRefs: Set; +}; + +type DistillerPlugin = { + name: string; + enter?(node: aria.AriaNode, ctx: DistillerContext): 'remove' | 'unwrap' | void; + exit?(node: aria.AriaNode, ctx: DistillerContext): 'remove' | 'unwrap' | void; +}; + +export function distillAriaSnapshot(snapshot: AriaSnapshot, options: Pick) { + runPlugins(snapshot, options.mode === 'ai' ? aiPlugins : normalizePlugins, options); +} + +function runPlugins(snapshot: AriaSnapshot, plugins: DistillerPlugin[], options: Pick) { + const ctx: DistillerContext = { snapshot, depth: -1, maxDepth: options.depth, ancestors: [], pendingContentRefs: new Set() }; + const traverse = (node: aria.AriaNode, depth: number) => { + const children: (aria.AriaNode | string)[] = []; + const visitChild = (child: aria.AriaNode | string) => { + if (typeof child === 'string') { + children.push(child); + return; + } + ctx.depth = depth + 1; + for (const plugin of plugins) { + const result = plugin.enter?.(child, ctx); + if (result === 'remove') + return; + if (result === 'unwrap') { + child.children.forEach(visitChild); + return; + } + } + traverse(child, depth + 1); + ctx.depth = depth + 1; + for (const plugin of plugins) { + const result = plugin.exit?.(child, ctx); + if (result === 'remove') + return; + if (result === 'unwrap') { + children.push(...child.children); + return; + } + } + children.push(child); + }; + ctx.ancestors.push(node); + node.children.forEach(visitChild); + ctx.ancestors.pop(); + node.children = children; + }; + // Hooks run on the root as well, but the root cannot be removed or unwrapped. + for (const plugin of plugins) + plugin.enter?.(snapshot.root, ctx); + traverse(snapshot.root, -1); + ctx.depth = -1; + for (const plugin of plugins) + plugin.exit?.(snapshot.root, ctx); +} + +// A generic node whose only content is text - it carries no structure of its own. +function isLeafGeneric(node: aria.AriaNode): boolean { + return node.role === 'generic' && node.children.every(child => typeof child === 'string'); +} + +// The tree builder emits raw text tokens - text nodes, CSS content, block spacing markers - as +// string children. Coalesce the adjacent ones, normalize whitespace and drop the empties, then +// drop a lone text child that merely repeats the node's accessible name. Runs on `exit`, so the +// merge sees the children in their final shape. +const mergeStringChildren: DistillerPlugin = { + name: 'mergeStringChildren', + exit(node: aria.AriaNode) { + const children: (aria.AriaNode | string)[] = []; + const buffer: string[] = []; + const flush = () => { + if (!buffer.length) + return; + const text = normalizeWhiteSpace(buffer.join('')); + if (text) + children.push(text); + buffer.length = 0; + }; + for (const child of node.children) { + if (typeof child === 'string') { + buffer.push(child); + } else { + flush(); + children.push(child); + } + } + flush(); + node.children = children; + if (node.children.length === 1 && node.children[0] === node.name) + node.children = []; + }, +}; + +// Only unwrap a generic that encloses at most one element, logical grouping still makes sense, +// even if it is not ref-able. The decision is made on `exit` - whether the node encloses a single +// ref-bearing child is only known after its own descendants were unwrapped - so nested wrappers +// collapse bottom-up. +const unwrapSingleChildGenerics: DistillerPlugin = { + name: 'unwrapSingleChildGenerics', + exit(node: aria.AriaNode): 'unwrap' | void { + if (node.role === 'generic' && !node.name && node.children.length <= 1 && node.children.every(child => typeof child !== 'string' && !!child.ref)) + return 'unwrap'; + }, +}; + +// A decorative image - role `img` with no accessible name and no content - carries no +// information. The decision is made on `exit` - whether the node has content is only known after +// `mergeStringChildren` dropped the empty text tokens. +const removeNamelessImages: DistillerPlugin = { + name: 'removeNamelessImages', + exit(node: aria.AriaNode): 'remove' | void { + if (node.role === 'img' && !node.name && !node.children.length) + return 'remove'; + }, +}; + +// The node's accessible name is derived from content; when every node that contributed to it is +// represented in the output anyway, the name would just repeat that content and is dropped. +// Single-pass bookkeeping over the shared `pendingContentRefs` set: entering a node clears its +// own ref - it is now represented - except for leaf generics, which only exist to supply text +// and are dropped by `removeNameRepeatingChild` once a kept name shows it. On exit, either every +// contributor was cleared and the name goes, or the kept name now represents its contributors, +// so they are cleared for the benefit of the ancestors. A node removed on enter never clears its +// ref, and an unwrapped one does - matching what remains in the tree. +const removeRedundantNames: DistillerPlugin = { + name: 'removeRedundantNames', + enter(node: aria.AriaNode, ctx: DistillerContext) { + if (!node.ref) + return; + for (const ref of ctx.snapshot.info.get(node.ref)?.nameFromContentRefs || []) + ctx.pendingContentRefs.add(ref); + const beyondDepth = !!ctx.maxDepth && ctx.depth > ctx.maxDepth; + if (!beyondDepth && !isLeafGeneric(node)) + ctx.pendingContentRefs.delete(node.ref); + }, + exit(node: aria.AriaNode, ctx: DistillerContext) { + if (!node.ref) + return; + const nameFromContentRefs = ctx.snapshot.info.get(node.ref)?.nameFromContentRefs; + if (!nameFromContentRefs?.length) + return; + if (nameFromContentRefs.every(ref => !ctx.pendingContentRefs.has(ref))) { + node.name = ''; + } else { + for (const ref of nameFromContentRefs) + ctx.pendingContentRefs.delete(ref); + } + }, +}; + +// A generic whose whole content is a piece of text - a single text child, or just an accessible +// name - that repeats the parent's accessible name adds no information, so it removes itself. +// `inlineTextIntoGeneric` runs first, bubbling text up through nameless wrappers, so by the time +// a wrapper exits its text faces the real parent - no need to look further up the ancestor chain. +// Whenever the node is the source of that name, `removeRedundantNames` keeps the name - the node +// is a leaf generic - so the text is never lost. +const removeNameRepeatingChild: DistillerPlugin = { + name: 'removeNameRepeatingChild', + exit(node: aria.AriaNode, ctx: DistillerContext): 'remove' | void { + const parent = ctx.ancestors[ctx.ancestors.length - 1]; + if (!parent?.name || node.role !== 'generic' || node.active || Object.keys(node.props).length) + return; + const singleTextChild = node.children.length === 1 && typeof node.children[0] === 'string' ? node.children[0] : undefined; + const text = node.name ? (node.children.length ? undefined : node.name) : singleTextChild; + if (text && text === parent.name) + return 'remove'; + }, +}; + +// A generic whose only child is a nameless leaf generic inlines that child's text: +// `generic: - generic: "text"` becomes `generic: "text"`. Runs post-order, so chains collapse +// bottom-up, and after the other plugins already removed or unwrapped the children. +const inlineTextIntoGeneric: DistillerPlugin = { + name: 'inlineTextIntoGeneric', + exit(node: aria.AriaNode) { + if (node.role !== 'generic' || Object.keys(node.props).length || node.children.length !== 1) + return; + const child = node.children[0]; + if (typeof child === 'string') + return; + if (child.role !== 'generic' || child.name || child.active || Object.keys(child.props).length) + return; + if (child.children.length === 1 && typeof child.children[0] === 'string') + node.children = [child.children[0]]; + }, +}; + +// Structural normalization applies to all modes - it defines the canonical tree shape. +const normalizePlugins: DistillerPlugin[] = [ + mergeStringChildren, + unwrapSingleChildGenerics, +]; + +// The ai preset compresses the snapshot on top of normalization. It runs as one traversal: +// `removeRedundantNames` bookkeeping must observe every node the tree retains, including the +// wrappers that `unwrapSingleChildGenerics` is about to unwrap. On exit, text is first inlined +// into the node, so that `removeNameRepeatingChild` faces the real parent when it compares. +const aiPlugins: DistillerPlugin[] = [ + mergeStringChildren, + removeNamelessImages, + removeRedundantNames, + inlineTextIntoGeneric, + removeNameRepeatingChild, + unwrapSingleChildGenerics, +]; diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 17af70020bee0..cddf13cfebaee 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -25,7 +25,7 @@ import { beginDOMCaches, enclosingShadowRootOrDocument, endDOMCaches, isElementV import { Highlight } from './highlight'; import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils'; import { createRoleEngine } from './roleSelectorEngine'; -import { beginAriaCaches, endAriaCaches, getAriaDisabled, getAriaRole, getCheckedAllowMixed, getCheckedWithoutMixed, getElementAccessibleDescription, getElementAccessibleErrorMessage, getElementAccessibleName, getReadonly } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaDisabled, getAriaRole, getCheckedAllowMixed, getCheckedWithoutMixed, getElementAccessibleDescription, getElementAccessibleErrorMessage, getElementAccessibleNameText, getReadonly } from './roleUtils'; import { SelectorEvaluatorImpl, sortInDOMOrder } from './selectorEvaluator'; import { generateSelector } from './selectorGenerator'; import { elementMatchesText, elementText, getElementLabels } from './selectorUtils'; @@ -103,7 +103,6 @@ export class InjectedScript { readonly window: Window & typeof globalThis; readonly document: Document; readonly consoleApi: ConsoleAPI; - private _lastAriaSnapshotForTrack = new Map(); private _lastAriaSnapshotForQuery: AriaSnapshot | undefined; // Recorder must use any external dependencies through InjectedScript. @@ -114,8 +113,8 @@ export class InjectedScript { cacheNormalizedWhitespaces, elementText, getAriaRole, + getElementAccessibleNameText, getElementAccessibleDescription, - getElementAccessibleName, isElementVisible, isInsideScope, normalizeWhiteSpace, @@ -313,23 +312,16 @@ export class InjectedScript { } ariaSnapshot(node: Node, options: AriaTreeOptions): string { - return this.incrementalAriaSnapshot(node, options).full; + return this.ariaSnapshotWithRefs(node, options).text; } - incrementalAriaSnapshot(node: Node, options: AriaTreeOptions & { track?: string, depth?: number }): { full: string, incremental?: string, iframeRefs: string[], iframeDepths: Record } { + ariaSnapshotWithRefs(node: Node, options: AriaTreeOptions & { depth?: number }): { text: string, iframeRefs: string[], iframeDepths: Record } { if (node.nodeType !== Node.ELEMENT_NODE) throw this.createStacklessError('Can only capture aria snapshot of Element nodes.'); const ariaSnapshot = generateAriaTree(node as Element, options); const rendered = renderAriaTree(ariaSnapshot, options); - let incremental: string | undefined; - if (options.track) { - const previousSnapshot = this._lastAriaSnapshotForTrack.get(options.track); - if (previousSnapshot) - incremental = renderAriaTree(ariaSnapshot, options, previousSnapshot).text; - this._lastAriaSnapshotForTrack.set(options.track, ariaSnapshot); - } this._lastAriaSnapshotForQuery = ariaSnapshot; - return { full: rendered.text, incremental, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths }; + return { text: rendered.text, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths }; } ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map } { @@ -728,8 +720,8 @@ export class InjectedScript { _createAriaRefEngine() { const queryAll = (root: SelectorRoot, selector: string): Element[] => { - const result = this._lastAriaSnapshotForQuery?.elements?.get(selector); - return result && result.isConnected ? [result] : []; + const result = this._lastAriaSnapshotForQuery?.info?.get(selector); + return result && result.element.isConnected ? [result.element] : []; }; return { queryAll }; } @@ -1665,7 +1657,7 @@ export class InjectedScript { } else if (expression === 'to.have.text') { received = options.useInnerText ? (element as HTMLElement).innerText : elementText(new Map(), element).full; } else if (expression === 'to.have.accessible.name') { - received = getElementAccessibleName(element, false /* includeHidden */); + received = getElementAccessibleNameText(element, false /* includeHidden */); } else if (expression === 'to.have.accessible.description') { received = getElementAccessibleDescription(element, false /* includeHidden */); } else if (expression === 'to.have.accessible.error.message') { diff --git a/packages/injected/src/roleSelectorEngine.ts b/packages/injected/src/roleSelectorEngine.ts index 2691ff32bba6a..7d18a013d74b7 100644 --- a/packages/injected/src/roleSelectorEngine.ts +++ b/packages/injected/src/roleSelectorEngine.ts @@ -17,7 +17,7 @@ import { parseAttributeSelector } from '@isomorphic/selectorParser'; import { normalizeWhiteSpace } from '@isomorphic/stringUtils'; -import { beginAriaCaches, endAriaCaches, getAriaBusy, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleDescription, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaBusy, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleDescription, getElementAccessibleNameText, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; import { matchesAttributePart } from './selectorUtils'; import type { AttributeSelectorOperator, AttributeSelectorPart } from '@isomorphic/selectorParser'; @@ -173,7 +173,7 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo } if (options.name !== undefined) { // Always normalize whitespace in the accessible name. - const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden)); + const accessibleName = normalizeWhiteSpace(getElementAccessibleNameText(element, !!options.includeHidden)); if (typeof options.name === 'string') options.name = normalizeWhiteSpace(options.name); // internal:role assumes that [name="foo"i] also means substring. diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index f18665902a852..b167f354a666e 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -501,32 +501,49 @@ function allowsNameFromContent(role: string, targetDescendant: boolean) { return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; } -export function getElementAccessibleName(element: Element, includeHidden: boolean): string { +function computeAccessibleNameComposite(element: Element, includeHidden: boolean, collectElements: boolean): CompositeString { + // https://w3c.github.io/accname/#computation-steps + + // step 1. + // https://w3c.github.io/aria/#namefromprohibited + const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || ''); + if (elementProhibitsNaming) + return emptyCompositeString(); + + // step 2. + const result = getTextAlternativeInternal(element, { + includeHidden, + collectElements, + visitedElements: new Set(), + embeddedInTargetElement: 'self', + }); + return { text: asFlatString(result.text), elements: result.elements }; +} + +export function getElementAccessibleName(element: Element, includeHidden: boolean): CompositeString { const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName); let accessibleName = cache?.get(element); - if (accessibleName === undefined) { - // https://w3c.github.io/accname/#computation-steps - accessibleName = ''; - - // step 1. - // https://w3c.github.io/aria/#namefromprohibited - const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || ''); - - if (!elementProhibitsNaming) { - // step 2. - accessibleName = asFlatString(getTextAlternativeInternal(element, { - includeHidden, - visitedElements: new Set(), - embeddedInTargetElement: 'self', - })); - } - + accessibleName = computeAccessibleNameComposite(element, includeHidden, true /* collectElements */); cache?.set(element, accessibleName); } return accessibleName; } +export function getElementAccessibleNameText(element: Element, includeHidden: boolean): string { + // Reuse the element-collecting composite if it happens to be cached, otherwise compute text only. + const composite = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName)?.get(element); + if (composite !== undefined) + return composite.text; + const cache = (includeHidden ? cacheAccessibleNameTextHidden : cacheAccessibleNameText); + let text = cache?.get(element); + if (text === undefined) { + text = computeAccessibleNameComposite(element, includeHidden, false /* collectElements */).text; + cache?.set(element, text); + } + return text; +} + export function getElementAccessibleDescription(element: Element, includeHidden: boolean): string { const cache = (includeHidden ? cacheAccessibleDescriptionHidden : cacheAccessibleDescription); let accessibleDescription = cache?.get(element); @@ -543,7 +560,7 @@ export function getElementAccessibleDescription(element: Element, includeHidden: includeHidden, visitedElements: new Set(), embeddedInDescribedBy: { element: ref, hidden: isElementHiddenForAria(ref) }, - })).join(' ')); + }).text).join(' ')); } else if (element.hasAttribute('aria-description')) { // precedence 2 accessibleDescription = asFlatString(element.getAttribute('aria-description') || ''); @@ -619,7 +636,7 @@ export function getElementAccessibleErrorMessage(element: Element): string { getTextAlternativeInternal(errorMessage, { visitedElements: new Set(), embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) }, - }) + }).text )); accessibleErrorMessage = parts.join(' ').trim(); } @@ -630,6 +647,7 @@ export function getElementAccessibleErrorMessage(element: Element): string { type AccessibleNameOptions = { visitedElements: Set, + collectElements?: boolean, includeHidden?: boolean, embeddedInDescribedBy?: { element: Element, hidden: boolean }, embeddedInLabelledBy?: { element: Element, hidden: boolean }, @@ -638,9 +656,9 @@ type AccessibleNameOptions = { embeddedInTargetElement?: 'self' | 'descendant', }; -function getTextAlternativeInternal(element: Element, options: AccessibleNameOptions): string { +function getTextAlternativeInternal(element: Element, options: AccessibleNameOptions): CompositeString { if (options.visitedElements.has(element)) - return ''; + return emptyCompositeString(); const childOptions: AccessibleNameOptions = { ...options, @@ -659,7 +677,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt if (isElementIgnoredForAria(element) || (!isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element))) { options.visitedElements.add(element); - return ''; + return emptyCompositeString(); } } @@ -670,15 +688,15 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt // at least one valid IDREF, and the current node is not already part of an ongoing // aria-labelledby or aria-describedby traversal, process its IDREFs in the order they occur... if (!options.embeddedInLabelledBy) { - const accessibleName = (labelledBy || []).map(ref => getTextAlternativeInternal(ref, { + const accessibleName = joinCompositeString((labelledBy || []).map(ref => getTextAlternativeInternal(ref, { ...options, embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) }, embeddedInDescribedBy: undefined, embeddedInTargetElement: undefined, embeddedInLabel: undefined, embeddedInNativeTextAlternative: undefined, - })).join(' '); - if (accessibleName) + })), ' ', options.collectElements); + if (accessibleName.text) return accessibleName; } @@ -700,8 +718,8 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt if (role === 'textbox') { options.visitedElements.add(element); if (tagName === 'INPUT' || tagName === 'TEXTAREA') - return (element as HTMLInputElement | HTMLTextAreaElement).value; - return element.textContent || ''; + return compositeString((element as HTMLInputElement | HTMLTextAreaElement).value, element, options.collectElements); + return compositeString(element.textContent, element, options.collectElements); } if (['combobox', 'listbox'].includes(role)) { options.visitedElements.add(element); @@ -718,22 +736,22 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt // SPEC DIFFERENCE: // This fallback is not explicitly mentioned in the spec, but all browsers and // wpt test name_heading-combobox-focusable-alternative-manual.html do this. - return (element as HTMLInputElement).value; + return compositeString((element as HTMLInputElement).value, element, options.collectElements); } - return selectedOptions.map(option => getTextAlternativeInternal(option, childOptions)).join(' '); + return joinCompositeString(selectedOptions.map(option => getTextAlternativeInternal(option, childOptions)), ' ', options.collectElements); } if (['progressbar', 'scrollbar', 'slider', 'spinbutton', 'meter'].includes(role)) { options.visitedElements.add(element); if (element.hasAttribute('aria-valuetext')) - return element.getAttribute('aria-valuetext') || ''; + return compositeString(element.getAttribute('aria-valuetext'), element, options.collectElements); if (element.hasAttribute('aria-valuenow')) - return element.getAttribute('aria-valuenow') || ''; - return element.getAttribute('value') || ''; + return compositeString(element.getAttribute('aria-valuenow'), element, options.collectElements); + return compositeString(element.getAttribute('value'), element, options.collectElements); } if (['menu'].includes(role)) { // https://github.com/w3c/accname/issues/67#issuecomment-553196887 options.visitedElements.add(element); - return ''; + return emptyCompositeString(); } } } @@ -742,7 +760,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt const ariaLabel = element.getAttribute('aria-label') || ''; if (trimFlatString(ariaLabel)) { options.visitedElements.add(element); - return ariaLabel; + return compositeString(ariaLabel, element, options.collectElements); } // step 2e. @@ -757,13 +775,13 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt options.visitedElements.add(element); const value = (element as HTMLInputElement).value || ''; if (trimFlatString(value)) - return value; + return compositeString(value, element, options.collectElements); if ((element as HTMLInputElement).type === 'submit') - return 'Submit'; + return compositeString('Submit', element, options.collectElements); if ((element as HTMLInputElement).type === 'reset') - return 'Reset'; + return compositeString('Reset', element, options.collectElements); const title = element.getAttribute('title') || ''; - return title; + return compositeString(title, element, options.collectElements); } // SPEC DIFFERENCE. @@ -775,7 +793,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt const labels = (element as HTMLInputElement).labels || []; if (labels.length && !options.embeddedInLabelledBy) return getAccessibleNameFromAssociatedLabels(labels, options); - return 'Choose File'; + return compositeString('Choose File', element, options.collectElements); } // https://w3c.github.io/html-aam/#input-type-image-accessible-name-computation @@ -789,13 +807,13 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt return getAccessibleNameFromAssociatedLabels(labels, options); const alt = element.getAttribute('alt') || ''; if (trimFlatString(alt)) - return alt; + return compositeString(alt, element, options.collectElements); const title = element.getAttribute('title') || ''; if (trimFlatString(title)) - return title; + return compositeString(title, element, options.collectElements); // SPEC DIFFERENCE. // Spec says return localized "Submit Query", but browsers and axe-core insist on "Submit". - return 'Submit'; + return compositeString('Submit', element, options.collectElements); } // https://w3c.github.io/html-aam/#button-element-accessible-name-computation @@ -813,7 +831,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt const labels = (element as HTMLOutputElement).labels || []; if (labels.length) return getAccessibleNameFromAssociatedLabels(labels, options); - return element.getAttribute('title') || ''; + return compositeString(element.getAttribute('title') || '', element, options.collectElements); } // https://w3c.github.io/html-aam/#input-type-text-input-type-password-input-type-number-input-type-search-input-type-tel-input-type-email-input-type-url-and-textarea-element-accessible-name-computation @@ -831,8 +849,8 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt const placeholder = element.getAttribute('placeholder') || ''; const title = element.getAttribute('title') || ''; if (!usePlaceholder || title) - return title; - return placeholder; + return compositeString(title, element, options.collectElements); + return compositeString(placeholder, element, options.collectElements); } // https://w3c.github.io/html-aam/#fieldset-and-legend-elements @@ -847,7 +865,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt } } const title = element.getAttribute('title') || ''; - return title; + return compositeString(title, element, options.collectElements); } // https://w3c.github.io/html-aam/#figure-and-figcaption-elements @@ -862,7 +880,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt } } const title = element.getAttribute('title') || ''; - return title; + return compositeString(title, element, options.collectElements); } // https://w3c.github.io/html-aam/#img-element @@ -873,9 +891,9 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt options.visitedElements.add(element); const alt = element.getAttribute('alt') || ''; if (trimFlatString(alt)) - return alt; + return compositeString(alt, element, options.collectElements); const title = element.getAttribute('title') || ''; - return title; + return compositeString(title, element, options.collectElements); } // https://w3c.github.io/html-aam/#table-element @@ -893,7 +911,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt // Spec does not say a word about , but all browsers actually support it. const summary = element.getAttribute('summary') || ''; if (summary) - return summary; + return compositeString(summary, element, options.collectElements); // SPEC DIFFERENCE. // Spec says "if the table element has a title attribute, then use that attribute". // We ignore title to pass "name_from_content-manual.html". @@ -904,9 +922,9 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt options.visitedElements.add(element); const alt = element.getAttribute('alt') || ''; if (trimFlatString(alt)) - return alt; + return compositeString(alt, element, options.collectElements); const title = element.getAttribute('title') || ''; - return title; + return compositeString(title, element, options.collectElements); } // https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd @@ -925,7 +943,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt const title = element.getAttribute('xlink:title') || ''; if (trimFlatString(title)) { options.visitedElements.add(element); - return title; + return compositeString(title, element, options.collectElements); } } } @@ -943,9 +961,12 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt // Spec says "Return the accumulated text if it is not the empty string". However, that is not really // compatible with the real browser behavior and wpt tests, where an element with empty contents will fallback to the title. // So we follow the spec everywhere except for the target element itself. This can probably be improved. - const maybeTrimmedAccessibleName = options.embeddedInTargetElement === 'self' ? trimFlatString(accessibleName) : accessibleName; - if (maybeTrimmedAccessibleName) + const maybeTrimmedAccessibleName = options.embeddedInTargetElement === 'self' ? trimFlatString(accessibleName.text) : accessibleName.text; + if (maybeTrimmedAccessibleName) { + // This element owns the accumulated content - record it alongside the descendants it was computed from. + accessibleName.elements?.add(element); return accessibleName; + } } // step 2i. @@ -953,21 +974,25 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt options.visitedElements.add(element); const title = element.getAttribute('title') || ''; if (trimFlatString(title)) - return title; + return compositeString(title, element, options.collectElements); } options.visitedElements.add(element); - return ''; + return emptyCompositeString(); } -function innerAccumulatedElementText(element: Element, options: AccessibleNameOptions): string { +function innerAccumulatedElementText(element: Element, options: AccessibleNameOptions): CompositeString { const tokens: string[] = []; + const elements = options.collectElements ? new Set() : undefined; const visit = (node: Node, skipSlotted: boolean) => { if (skipSlotted && (node as Element | Text).assignedSlot) return; if (node.nodeType === 1 /* Node.ELEMENT_NODE */) { const display = getElementComputedStyle(node as Element)?.display || 'inline'; - let token = getTextAlternativeInternal(node as Element, options); + const childComposite = getTextAlternativeInternal(node as Element, options); + let token = childComposite.text; + for (const contributor of childComposite.elements || []) + elements?.add(contributor); // SPEC DIFFERENCE. // Spec says "append the result to the accumulated text", assuming "with space". // However, multiple tests insist that inline elements do not add a space. @@ -1005,7 +1030,7 @@ function innerAccumulatedElementText(element: Element, options: AccessibleNameOp } } tokens.push(getCSSContent(element, '::after') || ''); - return tokens.join(''); + return { text: tokens.join(''), elements }; } export const kAriaSelectedRoles = ['gridcell', 'option', 'row', 'tab', 'rowheader', 'columnheader', 'treeitem']; @@ -1160,15 +1185,15 @@ export function getAriaBusy(element: Element): boolean { return getAriaBoolean(element.getAttribute('aria-busy')) === true; } -function getAccessibleNameFromAssociatedLabels(labels: Iterable, options: AccessibleNameOptions) { - return [...labels].map(label => getTextAlternativeInternal(label, { +function getAccessibleNameFromAssociatedLabels(labels: Iterable, options: AccessibleNameOptions): CompositeString { + return joinCompositeString([...labels].map(label => getTextAlternativeInternal(label, { ...options, embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) }, embeddedInNativeTextAlternative: undefined, embeddedInLabelledBy: undefined, embeddedInDescribedBy: undefined, embeddedInTargetElement: undefined, - })).filter(accessibleName => !!accessibleName).join(' '); + })).filter(accessibleName => !!accessibleName.text), ' ', options.collectElements); } export function receivesPointerEvents(element: Element): boolean { @@ -1205,8 +1230,10 @@ export function receivesPointerEvents(element: Element): boolean { return result; } -let cacheAccessibleName: Map | undefined; -let cacheAccessibleNameHidden: Map | undefined; +let cacheAccessibleName: Map | undefined; +let cacheAccessibleNameHidden: Map | undefined; +let cacheAccessibleNameText: Map | undefined; +let cacheAccessibleNameTextHidden: Map | undefined; let cacheAccessibleDescription: Map | undefined; let cacheAccessibleDescriptionHidden: Map | undefined; let cacheAccessibleErrorMessage: Map | undefined; @@ -1222,6 +1249,8 @@ export function beginAriaCaches() { ++cachesCounter; cacheAccessibleName ??= new Map(); cacheAccessibleNameHidden ??= new Map(); + cacheAccessibleNameText ??= new Map(); + cacheAccessibleNameTextHidden ??= new Map(); cacheAccessibleDescription ??= new Map(); cacheAccessibleDescriptionHidden ??= new Map(); cacheAccessibleErrorMessage ??= new Map(); @@ -1236,6 +1265,8 @@ export function endAriaCaches() { if (!--cachesCounter) { cacheAccessibleName = undefined; cacheAccessibleNameHidden = undefined; + cacheAccessibleNameText = undefined; + cacheAccessibleNameTextHidden = undefined; cacheAccessibleDescription = undefined; cacheAccessibleDescriptionHidden = undefined; cacheAccessibleErrorMessage = undefined; @@ -1258,3 +1289,29 @@ const inputTypeToRole: Record = { 'reset': 'button', 'submit': 'button', }; + +type CompositeString = { + text: string, + elements?: Set, +}; + +function emptyCompositeString(): CompositeString { + return { text: '' }; +} + +function compositeString(text: string | null, element: Element, collectElements: boolean | undefined): CompositeString { + const elements = text && collectElements ? new Set([element]) : undefined; + return { text: text || '', elements }; +} + +function joinCompositeString(parts: CompositeString[], separator: string, collectElements: boolean | undefined): CompositeString { + let elements: Set | undefined; + if (collectElements) { + elements = new Set(); + for (const part of parts) { + for (const element of part.elements || []) + elements.add(element); + } + } + return { text: parts.map(part => part.text).join(separator), elements }; +} diff --git a/packages/injected/src/selectorGenerator.ts b/packages/injected/src/selectorGenerator.ts index 25de3c184d00b..3a77c277f4fe2 100644 --- a/packages/injected/src/selectorGenerator.ts +++ b/packages/injected/src/selectorGenerator.ts @@ -18,7 +18,7 @@ import { splitTestIdAttributeNames } from '@isomorphic/locatorUtils'; import { escapeForAttributeSelector, escapeForTextSelector, escapeRegExp, quoteCSSAttributeValue } from '@isomorphic/stringUtils'; import { beginDOMCaches, closestCrossShadow, endDOMCaches, isElementVisible, isInsideScope, parentElementOrShadowHost } from './domUtils'; -import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleDescription, getElementAccessibleName } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleDescription, getElementAccessibleNameText } from './roleUtils'; import { elementText, getElementLabels } from './selectorUtils'; import type { InjectedScript } from './injectedScript'; @@ -348,7 +348,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i const ariaRole = getAriaRole(element); if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { - const ariaName = getElementAccessibleName(element, false); + const ariaName = getElementAccessibleNameText(element, false); // \p{Co} means "Private Use" characters - these are often used for icon fonts and make for bad locators. if (ariaName && !ariaName.match(/^\p{Co}+$/u)) { const roleToken = { engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }; diff --git a/packages/isomorphic/ariaSnapshot.ts b/packages/isomorphic/ariaSnapshot.ts index 6a856f31f6124..e710975467b45 100644 --- a/packages/isomorphic/ariaSnapshot.ts +++ b/packages/isomorphic/ariaSnapshot.ts @@ -24,7 +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'; -// Note: please keep in sync with ariaPropsEqual() below. export type AriaProps = { checked?: boolean | 'mixed'; disabled?: boolean; @@ -42,7 +41,6 @@ export type AriaBox = { cursor?: string; }; -// Note: please keep in sync with ariaNodesEqual() below. export type AriaNode = AriaProps & { role: AriaRole | 'fragment' | 'iframe'; name: string; @@ -53,24 +51,10 @@ export type AriaNode = AriaProps & { props: Record; }; -export function ariaNodesEqual(a: AriaNode, b: AriaNode): boolean { - if (a.role !== b.role || a.name !== b.name) - return false; - if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) - return false; - const aKeys = Object.keys(a.props); - const bKeys = Object.keys(b.props); - return aKeys.length === bKeys.length && aKeys.every(k => a.props[k] === b.props[k]); -} - export function hasPointerCursor(ariaNode: AriaNode): boolean { return ariaNode.box.cursor === 'pointer'; } -function ariaPropsEqual(a: AriaProps, b: AriaProps): boolean { - return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.invalid === b.invalid && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed; -} - // We pass parsed template between worlds using JSON, make it easy. export type AriaRegex = { pattern: string }; diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 68be295d3a52b..9a022dc8a31e2 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -2370,7 +2370,6 @@ export type FrameAddStyleTagResult = { }; export type FrameAriaSnapshotParams = { mode?: 'ai' | 'default', - track?: string, selector?: string, depth?: number, boxes?: boolean, @@ -2378,7 +2377,6 @@ export type FrameAriaSnapshotParams = { }; export type FrameAriaSnapshotOptions = { mode?: 'ai' | 'default', - track?: string, selector?: string, depth?: number, boxes?: boolean, diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index cbf6882e6402d..1ee49a01dc88f 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -887,8 +887,8 @@ export class Page extends ChannelOwner implements api.Page return result.pdf; } - async ariaSnapshot(options: TimeoutOptions & { mode?: 'ai' | 'default', depth?: number, boxes?: boolean, _track?: string } = {}): Promise { - const result = await this.mainFrame()._channel.ariaSnapshot({ timeout: this._timeoutSettings.timeout(options), track: options._track, mode: options.mode, depth: options.depth, boxes: options.boxes }, options.signal); + async ariaSnapshot(options: TimeoutOptions & { mode?: 'ai' | 'default', depth?: number, boxes?: boolean } = {}): Promise { + const result = await this.mainFrame()._channel.ariaSnapshot({ timeout: this._timeoutSettings.timeout(options), mode: options.mode, depth: options.depth, boxes: options.boxes }, options.signal); return result.snapshot; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 7a9640c1df7c6..e080fc07a1278 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1291,7 +1291,6 @@ scheme.FrameAddStyleTagResult = tObject({ }); scheme.FrameAriaSnapshotParams = tObject({ mode: tOptional(tEnum(['ai', 'default'])), - track: tOptional(tString), selector: tOptional(tString), depth: tOptional(tInt), boxes: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 975dd370607e0..9f84b5f545d53 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -2373,7 +2373,6 @@ export type FrameAddStyleTagResult = { }; export type FrameAriaSnapshotParams = { mode?: 'ai' | 'default', - track?: string, selector?: string, depth?: number, boxes?: boolean, @@ -2381,7 +2380,6 @@ export type FrameAriaSnapshotParams = { }; export type FrameAriaSnapshotOptions = { mode?: 'ai' | 'default', - track?: string, selector?: string, depth?: number, boxes?: boolean, diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 6a6acc697857a..1cb22c816183c 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1851,10 +1851,7 @@ export class Frame extends SdkObject { }, { source, arg }); } - async ariaSnapshot(progress: Progress, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, selector?: string, depth?: number, boxes?: boolean } = {}): Promise<{ snapshot: string }> { - if (options.selector && options.track) - throw new Error('Cannot specify both selector and track options'); - + async ariaSnapshot(progress: Progress, options: { mode?: 'ai' | 'default', doNotRenderActive?: boolean, selector?: string, depth?: number, boxes?: boolean } = {}): Promise<{ snapshot: string }> { if (options.selector && options.mode !== 'ai') { // Non-ai locator snapshot is auto-waiting and does not include iframes. const snapshot = await this._retryWithProgressIfNotConnected(progress, options.selector, { strict: true, performActionPreChecks: true }, async (progress, handle) => { @@ -1875,9 +1872,8 @@ export class Frame extends SdkObject { targetFrame = this; } - const result = await ariaSnapshotForFrame(progress, targetFrame, { ...options, info }); - const snapshot = options.track && result.incremental ? result.incremental.join('\n') : result.full.join('\n'); - return { snapshot }; + const lines = await ariaSnapshotForFrame(progress, targetFrame, { ...options, info }); + return { snapshot: lines.join('\n') }; } private _asLocator(selector: string) { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index b637071310b03..14c194b8bdb38 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -1107,7 +1107,7 @@ export class InitScript extends DisposableObject { } } -export async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, info?: SelectorInfo, depth?: number, boxes?: boolean } = {}): Promise<{ full: string[], incremental?: string[] }> { +export async function ariaSnapshotForFrame(progress: Progress, frame: frames.Frame, options: { mode?: 'ai' | 'default', doNotRenderActive?: boolean, info?: SelectorInfo, depth?: number, boxes?: boolean } = {}): Promise { // Only await the topmost navigations, inner frames will be empty when racing. const snapshot = await frame.retryWithProgressAndTimeouts(progress, [1000, 2000, 4000, 8000], async (progress, continuePolling) => { try { @@ -1118,16 +1118,15 @@ export async function ariaSnapshotForFrame(progress: Progress, frame: frames.Fra const element = injected.querySelector(options.info.parsed, injected.document, options.info.strict); if (!element) return false; - return injected.incrementalAriaSnapshot(element, options); + return injected.ariaSnapshotWithRefs(element, options); } const node = injected.document.body; if (!node) return true; - return injected.incrementalAriaSnapshot(node, options); + return injected.ariaSnapshotWithRefs(node, options); }, { mode: options.mode ?? 'default', refPrefix: frame.seq ? 'f' + frame.seq : '', - track: options.track, doNotRenderActive: options.doNotRenderActive, info: options.info, depth: options.depth, @@ -1156,47 +1155,35 @@ export async function ariaSnapshotForFrame(progress: Progress, frame: frames.Fra const childSnapshots = await Promise.all(childSnapshotPromises); progress.setAllowConcurrentOrNestedRaces(false); - const full = []; - let incremental: string[] | undefined; - - if (snapshot.incremental !== undefined) { - incremental = snapshot.incremental.split('\n'); - for (let i = 0; i < renderedIframeRefs.length; i++) { - const childSnapshot = childSnapshots[i]; - if (childSnapshot.incremental) - incremental.push(...childSnapshot.incremental); - else if (childSnapshot.full.length) - incremental.push('- iframe [ref=' + renderedIframeRefs[i] + ']:', ...childSnapshot.full.map(l => ' ' + l)); - } - } + const lines = []; - for (const line of snapshot.full.split('\n')) { + for (const line of snapshot.text.split('\n')) { const match = line.match(/^(\s*)- iframe (?:\[active\] )?\[ref=([^\]]*)\]/); if (!match) { - full.push(line); + lines.push(line); continue; } const leadingSpace = match[1]; const ref = match[2]; - const childSnapshot = childSnapshots[renderedIframeRefs.indexOf(ref)] ?? { full: [] }; - full.push(childSnapshot.full.length ? line + ':' : line); - full.push(...childSnapshot.full.map(l => leadingSpace + ' ' + l)); + const childSnapshot = childSnapshots[renderedIframeRefs.indexOf(ref)] ?? []; + lines.push(childSnapshot.length ? line + ':' : line); + lines.push(...childSnapshot.map(l => leadingSpace + ' ' + l)); } - return { full, incremental }; + return lines; } -async function ariaSnapshotFrameRef(progress: Progress, parentFrame: frames.Frame, frameRef: string, options: { mode?: 'ai' | 'default', track?: string, doNotRenderActive?: boolean, depth?: number }): Promise<{ full: string[], incremental?: string[] }> { +async function ariaSnapshotFrameRef(progress: Progress, parentFrame: frames.Frame, frameRef: string, options: { mode?: 'ai' | 'default', doNotRenderActive?: boolean, depth?: number }): Promise { const frameSelector = `aria-ref=${frameRef} >> internal:control=enter-frame`; const frameBodySelector = `${frameSelector} >> body`; const child = await progress.race(parentFrame.selectors.resolveFrameForSelector(frameBodySelector, { strict: true })); if (!child) - return { full: [] }; + return []; try { return await ariaSnapshotForFrame(progress, child.frame, { ...options, info: undefined }); } catch { - return { full: [] }; + return []; } } diff --git a/packages/playwright-core/src/tools/backend/find.ts b/packages/playwright-core/src/tools/backend/find.ts new file mode 100644 index 0000000000000..703dd0e3a9e36 --- /dev/null +++ b/packages/playwright-core/src/tools/backend/find.ts @@ -0,0 +1,114 @@ +/** + * 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 * as z from 'zod'; + +import { defineTabTool } from './tool'; + +// Number of context lines to show around each match, like `grep -C`. +const contextLines = 3; + +const find = defineTabTool({ + capability: 'core', + schema: { + name: 'browser_find', + title: 'Find in page snapshot', + description: 'Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref.', + inputSchema: z.object({ + text: z.string().optional().describe('Plain text to search for in the page snapshot (case-insensitive substring match). Provide either text or regex, not both.'), + regex: z.string().optional().refine(v => !v || isValidRegex(v), { message: 'Invalid regular expression' }).describe('Regular expression to search for in the page snapshot. Matching is case-sensitive by default; wrap the pattern in slashes to add flags, e.g. "/error/i" for case-insensitive. Provide either text or regex, not both.'), + }), + type: 'readOnly', + }, + + handle: async (tab, params, response) => { + if (!params.text && !params.regex) { + response.addError('Provide either "text" or "regex" to search for.'); + return; + } + if (params.text && params.regex) { + response.addError('Provide only one of "text" or "regex", not both.'); + return; + } + + let query: string; + let matches: (line: string) => boolean; + if (params.regex) { + const re = compileRegex(params.regex); + query = String(re); + matches = line => { + re.lastIndex = 0; + return re.test(line); + }; + } else { + query = `"${params.text}"`; + const needle = params.text!.toLowerCase(); + matches = line => line.toLowerCase().includes(needle); + } + + const snapshot = await tab.page.ariaSnapshot({ mode: 'ai' }); + const lines = snapshot.split('\n'); + const matchedLines: number[] = []; + for (let i = 0; i < lines.length; i++) { + if (matches(lines[i])) + matchedLines.push(i); + } + + if (!matchedLines.length) { + response.addTextResult(`No matches found for ${query}.`); + return; + } + + // Merge matched lines into windows of context, coalescing overlapping ones. + const windows: { start: number, end: number }[] = []; + for (const line of matchedLines) { + const start = Math.max(0, line - contextLines); + const end = Math.min(lines.length - 1, line + contextLines); + const last = windows[windows.length - 1]; + if (last && start <= last.end + 1) + last.end = Math.max(last.end, end); + else + windows.push({ start, end }); + } + + const snippets = windows.map(window => lines.slice(window.start, window.end + 1).join('\n')); + const matchWord = matchedLines.length === 1 ? 'match' : 'matches'; + response.addTextResult(`Found ${matchedLines.length} ${matchWord} for ${query}:\n\n${snippets.join('\n\n----\n\n')}`); + }, +}); + +// Accept either a bare pattern or a `/pattern/flags` literal, mirroring the +// test runner's forceRegExp. Matching is line-oriented, so the global flag is +// dropped: it only makes `.test()` stateful without changing which lines match. +function compileRegex(source: string): RegExp { + const literal = /^\/(.*)\/([a-z]*)$/.exec(source); + const pattern = literal ? literal[1] : source; + const flags = literal ? literal[2].replace(/g/g, '') : ''; + return new RegExp(pattern, flags); +} + +function isValidRegex(source: string): boolean { + try { + compileRegex(source); + return true; + } catch { + return false; + } +} + +export default [ + find, +]; diff --git a/packages/playwright-core/src/tools/backend/tools.ts b/packages/playwright-core/src/tools/backend/tools.ts index a5fdfdc664c42..d9b4e7d64f923 100644 --- a/packages/playwright-core/src/tools/backend/tools.ts +++ b/packages/playwright-core/src/tools/backend/tools.ts @@ -23,6 +23,7 @@ import devtools from './devtools'; import dialogs from './dialogs'; import evaluate from './evaluate'; import files from './files'; +import find from './find'; import form from './form'; import keyboard from './keyboard'; import mouse from './mouse'; @@ -53,6 +54,7 @@ export const browserTools: Tool[] = [ ...dialogs, ...evaluate, ...files, + ...find, ...form, ...keyboard, ...mouse, diff --git a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md index 2fa5d0e96f9fe..987cd3a69d3ec 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/SKILL.md +++ b/packages/playwright-core/src/tools/cli-client/skill/SKILL.md @@ -47,6 +47,11 @@ playwright-cli upload ./document.pdf playwright-cli check e12 playwright-cli uncheck e12 playwright-cli snapshot +# search the snapshot for text or a regexp, returns matching nodes with surrounding context +playwright-cli find "Sign in" +playwright-cli find --regex "Sign (in|up)" +# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive +playwright-cli find --regex "/sign (in|up)/i" playwright-cli eval "document.title" playwright-cli eval "el => el.textContent" e5 # get element id, class, or any attribute not visible in the snapshot @@ -279,6 +284,11 @@ playwright-cli snapshot e34 # include each element's bounding box as [box=x,y,width,height] playwright-cli snapshot --boxes + +# search a large snapshot instead of capturing it all — returns matching nodes +# with 3 lines of context around each match (like grep -C) +playwright-cli find "Add to cart" +playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}" ``` ## Targeting elements @@ -326,13 +336,13 @@ playwright-cli kill-all ## Installation -If global `playwright-cli` command is not available, try a local version via `npx playwright-cli`: +If global `playwright-cli` command is not available, try a local version via `npx playwright cli`: ```bash -npx --no-install playwright-cli --version +npx --no-install playwright --version ``` -When local version is available, use `npx playwright-cli` in all commands. Otherwise, install `playwright-cli` as a global command: +When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command: ```bash npm install -g @playwright/cli@latest @@ -397,9 +407,8 @@ playwright-cli show --annotate * **Request mocking** [references/request-mocking.md](references/request-mocking.md) * **Running Playwright code** [references/running-code.md](references/running-code.md) * **Browser session management** [references/session-management.md](references/session-management.md) -* **Spec-driven testing (plan / generate / heal)** [references/spec-driven-testing.md](references/spec-driven-testing.md) * **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) -* **Test generation** [references/test-generation.md](references/test-generation.md) +* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md) * **Tracing** [references/tracing.md](references/tracing.md) * **Video recording** [references/video-recording.md](references/video-recording.md) * **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md) diff --git a/packages/playwright-core/src/tools/cli-client/skill/references/spec-driven-testing.md b/packages/playwright-core/src/tools/cli-client/skill/references/spec-driven-testing.md deleted file mode 100644 index 336dbfc13a3d3..0000000000000 --- a/packages/playwright-core/src/tools/cli-client/skill/references/spec-driven-testing.md +++ /dev/null @@ -1,305 +0,0 @@ -# Spec-driven testing (plan → generate → heal) - -End-to-end workflow for authoring and maintaining Playwright tests using `playwright-cli`. The three sections below can be used independently: - -- **Planning** — explore the app, produce a spec file describing what to test. -- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale. -- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality. - -All three lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics and [test-generation.md](test-generation.md) for how every `playwright-cli` action emits Playwright TypeScript. - ---- - -## 1. Planning - -Goal: produce a spec file (e.g. `specs/.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file. - -### 1.1 Prerequisite: workspace - -Check the workspace has Playwright installed before anything else: - -```bash -# Either of these confirms a workspace: -test -f playwright.config.ts || test -f playwright.config.js -npx --no-install playwright --version -``` - -If there is no Playwright install, bootstrap one and let the user pick the defaults: - -```bash -npm init playwright@latest -``` - -### 1.2 Prerequisite: seed test - -A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins. - -Minimum viable seed: - -```ts -// tests/seed.spec.ts -import { test } from '@playwright/test'; - -test('seed', async ({ page }) => { - await page.goto('https://example.com/'); -}); -``` - -Preferred — push navigation into a fixture so scenario tests reuse it: - -```ts -// tests/fixtures.ts -import { test as baseTest } from '@playwright/test'; -export { expect } from '@playwright/test'; - -export const test = baseTest.extend({ - page: async ({ page }, use) => { - await page.goto('https://example.com/'); - await use(page); - }, -}); -``` - -```ts -// tests/seed.spec.ts -import { test } from './fixtures'; - -test('seed', async ({ page }) => { - // Fixture already navigates. This empty body tells agents where to start. -}); -``` - -If no seed exists, create one that at least navigates to the app. - -### 1.3 Explore the app - -Launch the app via the seed in the background and attach: - -```bash -PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli -# wait for "Debugging Instructions" and the session name tw-XXXX -playwright-cli attach tw-XXXX -``` - -Resume so the seed runs, then probe the app: - -```bash -playwright-cli resume # resume so that seed test runs fully -playwright-cli snapshot # inventory of interactive elements -playwright-cli click e5 # follow a flow -playwright-cli eval "location.href" # read URL / state -playwright-cli show --annotate # ask the user to point at something -``` - -Map out: - -- Interactive surfaces (forms, buttons, lists, filters, modals). -- Primary user journeys end-to-end. -- Edge cases: empty states, validation errors, very long input, boundary values. -- Persistence: reload, local/session storage, URL fragments. -- Navigation: which controls change the URL, back/forward behaviour. - -**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there. -**Important**: Stop the background test when done exploring. - -### 1.4 Write the spec file - -Save under `specs/.plan.md`. Use this structure: - -```markdown -# Test Plan - -## Application Overview - - - -## Test Scenarios - -### 1. - -**Seed:** `tests/seed.spec.ts` - -#### 1.1. - -**File:** `tests//.spec.ts` - -**Steps:** - 1. - - expect: - - expect: - 2. - - expect: - -#### 1.2. -... - -### 2. - -**Seed:** `tests/seed.spec.ts` -... -``` - -Guidelines: - -- Each scenario is independent and starts from the seed's fresh state — never chain scenarios. -- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`). -- Cover happy path, edge cases, validation, negative flows, persistence. -- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`"). -- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation. - ---- - -## 2. Generate - -Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted. - -### 2.1 Inputs - -- **Spec file**, e.g. `specs/basic-operations.plan.md`. -- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all. -- **Seed file**, read from the `**Seed:**` line of the scenario's group. - -### 2.2 Generate one scenario - -For each target scenario, in sequence (never in parallel — scenarios share the seed session): - -```bash -PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli # background -playwright-cli attach tw-XXXX -# resume -``` - -**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there. - -Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected. - -Every action prints the equivalent Playwright TypeScript (see [test-generation.md](test-generation.md)): - -```bash -playwright-cli snapshot # find refs -playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...) -playwright-cli press Enter -playwright-cli click e7 -``` - -For each `- expect:` bullet, add an explicit assertion. See [test-generation.md](test-generation.md) for details. - -Collect the generated code and write the test file at the path given in the spec: - -```ts -// spec: specs/basic-operations.plan.md -// seed: tests/seed.spec.ts -import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file - -test.describe('Signing in and out', () => { - test('should sign in', async ({ page }) => { - // 1. Navigate to the application - // (handled by the seed fixture) - - // 2. Type 'John Doe' into the username field - await page.getByRole('textbox', { name: 'username' }).fill('John Doe'); - - // 3. Type password - await page.getByRole('textbox', { name: 'password' }).fill('TestPassword'); - - // 4. Press Enter to submit - await page.getByRole('textbox', { name: 'password' }).press('Enter'); - - await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!'); - }); -}); -``` - -Rules: - -- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal). -- Prefix each numbered step with a `// N. ` comment before its actions. -- Use the describe group name verbatim from the spec (no `1.` ordinal). -- Import from `./fixtures` if the project has one; otherwise `@playwright/test`. -- **Important**: close the CLI session and stop the background test before moving to the next scenario. - -### 2.3 Generate multiple scenarios - -Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped. - -### 2.4 Run generated tests - -After generation, run the new tests once: - -```bash -PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts -``` - -Any failure goes to Section 3. - ---- - -## 3. Heal - -Goal: fix failing tests, and update the spec if the app's intended behaviour changed. - -### 3.1 Find failing tests - -```bash -PLAYWRIGHT_HTML_OPEN=never npx playwright test -``` - -Record the list of failing `:` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile. - -### 3.2 Debug one failure - -Run the single failing test in debug mode in the background, then attach: - -```bash -PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts: --debug=cli -# wait for "Debugging Instructions" and the tw-XXXX session name -playwright-cli attach tw-XXXX -``` - -The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose: - -```bash -playwright-cli snapshot # did the element change / move / rename? -playwright-cli console # app-side errors? -playwright-cli requests # failed request? wrong payload? -playwright-cli show --annotate # ask the user to point somewhere -``` - -Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs. - -Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test. - -### 3.3 Apply the fix - -Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green. - -Never skip hooks or add sleeps as a fix. Never use `networkidle`. - -### 3.4 Reconcile with the spec - -Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test. - -- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone. -- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change. -- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide: - - the scenario id (e.g. `2.3`), - - the spec lines that no longer match, - - the observed app behaviour (quote a snapshot excerpt or a concrete outcome). - -Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression). - -### 3.5 Iteration and giving up - -- Fix failures one at a time; rerun after each. -- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip. - ---- - -## Cross-references - -| For... | See | -|---|---| -| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) | -| How `playwright-cli` actions become TS | [test-generation.md](test-generation.md) | -| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) | -| Managing the CLI browser session | [session-management.md](session-management.md) | diff --git a/packages/playwright-core/src/tools/cli-client/skill/references/test-generation.md b/packages/playwright-core/src/tools/cli-client/skill/references/test-generation.md index a045c55d6e673..35a8d57d04337 100644 --- a/packages/playwright-core/src/tools/cli-client/skill/references/test-generation.md +++ b/packages/playwright-core/src/tools/cli-client/skill/references/test-generation.md @@ -1,13 +1,19 @@ -# Test Generation +# Test generation (plan → generate → heal) -Generate Playwright test code automatically as you interact with the browser. +End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently: -## How It Works +- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions. +- **Plan** — explore the app, produce a spec file describing what to test. +- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale. +- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality. -Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. -This code appears in the output and can be copied directly into your test files. +Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics. -## Example Workflow +--- + +## 0. How generation works + +Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files. ```bash # Start a session @@ -31,7 +37,7 @@ playwright-cli click e3 # await page.getByRole('button', { name: 'Sign In' }).click(); ``` -## Building a Test File +### Building a test file Collect the generated code into a Playwright test: @@ -50,9 +56,7 @@ test('login flow', async ({ page }) => { }); ``` -## Best Practices - -### 1. Use Semantic Locators +### Use semantic locators The generated code uses role-based locators when possible, which are more resilient: @@ -64,7 +68,7 @@ await page.getByRole('button', { name: 'Submit' }).click(); await page.locator('#submit-btn').click(); ``` -### 2. Explore Before Recording +### Explore before recording Take snapshots to understand the page structure before recording actions: @@ -75,7 +79,7 @@ playwright-cli snapshot playwright-cli click e5 ``` -### 3. Add Assertions Manually +### Add assertions manually Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers: @@ -132,3 +136,298 @@ await expect(page.getByRole('navigation')).toMatchAriaSnapshot(` - link "Profile" `); ``` + +--- + +## 1. Planning + +Goal: produce a spec file (e.g. `specs/.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file. + +### 1.1 Prerequisite: workspace + +Check the workspace has Playwright installed before anything else: + +```bash +# Either of these confirms a workspace: +test -f playwright.config.ts || test -f playwright.config.js +npx --no-install playwright --version +``` + +If there is no Playwright install, bootstrap one and let the user pick the defaults: + +```bash +npm init playwright@latest +``` + +### 1.2 Prerequisite: seed test + +A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins. + +Minimum viable seed: + +```ts +// tests/seed.spec.ts +import { test } from '@playwright/test'; + +test('seed', async ({ page }) => { + await page.goto('https://example.com/'); +}); +``` + +Preferred — push navigation into a fixture so scenario tests reuse it: + +```ts +// tests/fixtures.ts +import { test as baseTest } from '@playwright/test'; +export { expect } from '@playwright/test'; + +export const test = baseTest.extend({ + page: async ({ page }, use) => { + await page.goto('https://example.com/'); + await use(page); + }, +}); +``` + +```ts +// tests/seed.spec.ts +import { test } from './fixtures'; + +test('seed', async ({ page }) => { + // Fixture already navigates. This empty body tells agents where to start. +}); +``` + +If no seed exists, create one that at least navigates to the app. + +### 1.3 Explore the app + +Launch the app via the seed in the background and attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli +# wait for "Debugging Instructions" and the session name tw-XXXX +playwright-cli attach tw-XXXX +``` + +Resume so the seed runs, then probe the app: + +```bash +playwright-cli resume # resume so that seed test runs fully +playwright-cli snapshot # inventory of interactive elements +playwright-cli click e5 # follow a flow +playwright-cli eval "location.href" # read URL / state +playwright-cli show --annotate # ask the user to point at something +``` + +Map out: + +- Interactive surfaces (forms, buttons, lists, filters, modals). +- Primary user journeys end-to-end. +- Edge cases: empty states, validation errors, very long input, boundary values. +- Persistence: reload, local/session storage, URL fragments. +- Navigation: which controls change the URL, back/forward behaviour. + +**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there. +**Important**: Stop the background test when done exploring. + +### 1.4 Write the spec file + +Save under `specs/.plan.md`. Use this structure: + +```markdown +# Test Plan + +## Application Overview + + + +## Test Scenarios + +### 1. + +**Seed:** `tests/seed.spec.ts` + +#### 1.1. + +**File:** `tests//.spec.ts` + +**Steps:** + 1. + - expect: + - expect: + 2. + - expect: + +#### 1.2. +... + +### 2. + +**Seed:** `tests/seed.spec.ts` +... +``` + +Guidelines: + +- Each scenario is independent and starts from the seed's fresh state — never chain scenarios. +- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`). +- Cover happy path, edge cases, validation, negative flows, persistence. +- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`"). +- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation. + +--- + +## 2. Generate + +Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted. + +### 2.1 Inputs + +- **Spec file**, e.g. `specs/basic-operations.plan.md`. +- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all. +- **Seed file**, read from the `**Seed:**` line of the scenario's group. + +### 2.2 Generate one scenario + +For each target scenario, in sequence (never in parallel — scenarios share the seed session): + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli # background +playwright-cli attach tw-XXXX +# resume +``` + +**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there. + +Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected. + +Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)): + +```bash +playwright-cli snapshot # find refs +playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...) +playwright-cli press Enter +playwright-cli click e7 +``` + +For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details. + +Collect the generated code and write the test file at the path given in the spec: + +```ts +// spec: specs/basic-operations.plan.md +// seed: tests/seed.spec.ts +import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file + +test.describe('Signing in and out', () => { + test('should sign in', async ({ page }) => { + // 1. Navigate to the application + // (handled by the seed fixture) + + // 2. Type 'John Doe' into the username field + await page.getByRole('textbox', { name: 'username' }).fill('John Doe'); + + // 3. Type password + await page.getByRole('textbox', { name: 'password' }).fill('TestPassword'); + + // 4. Press Enter to submit + await page.getByRole('textbox', { name: 'password' }).press('Enter'); + + await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!'); + }); +}); +``` + +Rules: + +- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal). +- Prefix each numbered step with a `// N. ` comment before its actions. +- Use the describe group name verbatim from the spec (no `1.` ordinal). +- Import from `./fixtures` if the project has one; otherwise `@playwright/test`. +- **Important**: close the CLI session and stop the background test before moving to the next scenario. + +### 2.3 Generate multiple scenarios + +Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped. + +### 2.4 Run generated tests + +After generation, run the new tests once: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts +``` + +Any failure goes to Section 3. + +--- + +## 3. Heal + +Goal: fix failing tests, and update the spec if the app's intended behaviour changed. + +### 3.1 Find failing tests + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test +``` + +Record the list of failing `:` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile. + +### 3.2 Debug one failure + +Run the single failing test in debug mode in the background, then attach: + +```bash +PLAYWRIGHT_HTML_OPEN=never npx playwright test tests//.spec.ts: --debug=cli +# wait for "Debugging Instructions" and the tw-XXXX session name +playwright-cli attach tw-XXXX +``` + +The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose: + +```bash +playwright-cli snapshot # did the element change / move / rename? +playwright-cli console # app-side errors? +playwright-cli requests # failed request? wrong payload? +playwright-cli show --annotate # ask the user to point somewhere +``` + +Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs. + +Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test. + +### 3.3 Apply the fix + +Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green. + +Never skip hooks or add sleeps as a fix. Never use `networkidle`. + +### 3.4 Reconcile with the spec + +Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test. + +- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone. +- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change. +- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide: + - the scenario id (e.g. `2.3`), + - the spec lines that no longer match, + - the observed app behaviour (quote a snapshot excerpt or a concrete outcome). + +Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression). + +### 3.5 Iteration and giving up + +- Fix failures one at a time; rerun after each. +- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip. + +--- + +## Cross-references + +| For... | See | +|---|---| +| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) | +| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) | +| Managing the CLI browser session | [session-management.md](session-management.md) | diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 7505f6a3a466e..ec87f4101e027 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -385,6 +385,20 @@ const snapshot = declareCommand({ toolParams: ({ filename, target, depth, boxes }) => ({ filename, target, depth, boxes }), }); +const find = declareCommand({ + name: 'find', + description: 'Search the page snapshot for text or a regexp, returning matching nodes with surrounding context (like search snippets)', + category: 'core', + args: z.object({ + text: z.string().optional().describe('Plain text to search for in the page snapshot (case-insensitive substring match)'), + }), + options: z.object({ + regex: z.string().optional().describe('Regular expression to search for in the page snapshot. Provide either a text argument or --regex, not both.'), + }), + toolName: 'browser_find', + toolParams: ({ text, regex }) => ({ text, regex }), +}); + const generateLocator = declareCommand({ name: 'generate-locator', description: 'Generate a Playwright locator for the given element', @@ -1149,6 +1163,7 @@ const commandsArray: AnyCommandSchema[] = [ check, uncheck, snapshot, + find, evaluate, consoleList, dialogAccept, diff --git a/packages/playwright-core/src/tools/cli-daemon/program.ts b/packages/playwright-core/src/tools/cli-daemon/program.ts index fdb3e7a05a823..1ff7ac722de0b 100644 --- a/packages/playwright-core/src/tools/cli-daemon/program.ts +++ b/packages/playwright-core/src/tools/cli-daemon/program.ts @@ -83,7 +83,7 @@ function globalConfigFile(): string { return path.join(process.env['PWTEST_CLI_GLOBAL_CONFIG'] ?? os.homedir(), '.playwright', 'cli.config.json'); } -async function initWorkspace(initSkills: string | undefined) { +export async function initWorkspace(initSkills: string | undefined) { const cwd = process.cwd(); const playwrightDir = path.join(cwd, '.playwright'); await fs.promises.mkdir(playwrightDir, { recursive: true }); diff --git a/packages/playwright-core/src/tools/index.ts b/packages/playwright-core/src/tools/index.ts index 24459b9b0dd19..ec3e606812f67 100644 --- a/packages/playwright-core/src/tools/index.ts +++ b/packages/playwright-core/src/tools/index.ts @@ -31,7 +31,7 @@ export { extractTrace, DirTraceLoaderBackend } from './trace/traceParser'; export { decorateMCPCommand } from './mcp/program'; export { program as cliProgram } from './cli-client/program'; export { generateHelp, generateHelpJSON } from './cli-daemon/helpGenerator'; -export { decorateProgram as decorateCliDaemonProgram } from './cli-daemon/program'; +export { decorateProgram as decorateCliDaemonProgram, initWorkspace } from './cli-daemon/program'; export { openDashboardApp, openDashboardForContext } from './dashboard/dashboardApp'; export type { ContextConfig } from './backend/context'; diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index 8449c12090916..ed480d49aeef0 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -185,6 +185,20 @@ function addInitAgentsCommand(program: Command) { }); } +function addInitSkillsCommand(program: Command) { + const command = program.command('init-skills'); + command.description('Initialize the workspace and install Playwright CLI skills'); + const option = command.createOption('--loop ', 'Agentic loop provider'); + option.choices(['claude', 'generic']); + option.default('generic'); + command.addOption(option); + command.action(async opts => { + // Claude Code only reads skills from `.claude/skills`, every other agent reads + // them from the universal `.agents/skills` folder. + await tools.initWorkspace(opts.loop === 'claude' ? 'claude' : 'agents'); + }); +} + const kTraceModes: TraceMode[] = ['on', 'off', 'on-first-retry', 'on-all-retries', 'retain-on-failure', 'retain-on-first-failure', 'retain-on-failure-and-retries']; // Note: update docs/src/test-cli-js.md when you update this, program is the source of truth. @@ -237,3 +251,4 @@ addClearCacheCommand(program); addTestMCPServerCommand(program); addTestServerCommand(program); addInitAgentsCommand(program); +addInitSkillsCommand(program); diff --git a/packages/protocol/spec/frame.yml b/packages/protocol/spec/frame.yml index ec1813aa96e79..849ba14302562 100644 --- a/packages/protocol/spec/frame.yml +++ b/packages/protocol/spec/frame.yml @@ -84,8 +84,6 @@ Frame: literals: - ai - default - # When track is present, an incremental snapshot is returned when possible. - track: string? selector: string? depth: int? boxes: boolean? diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 7006f4d37244c..39def083f217f 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -22,7 +22,7 @@ test.skip(({ mode }) => mode !== 'default'); async function getNameAndRole(page: Page, selector: string) { return await page.$eval(selector, e => { - const name = (window as any).__injectedScript.utils.getElementAccessibleName(e); + const name = (window as any).__injectedScript.utils.getElementAccessibleNameText(e); const role = (window as any).__injectedScript.utils.getAriaRole(e); return { name, role }; }); @@ -85,7 +85,7 @@ for (let range = 0; range <= ranges.length; range++) { if (!element) throw new Error(`Unable to resolve "${step.selector}"`); const injected = (window as any).__injectedScript; - const received = step.property === 'name' ? injected.utils.getElementAccessibleName(element) : injected.utils.getElementAccessibleDescription(element); + const received = step.property === 'name' ? injected.utils.getElementAccessibleNameText(element) : injected.utils.getElementAccessibleDescription(element); result.push({ selector: step.selector, expected: step.value, received }); } return result; @@ -140,7 +140,7 @@ test('wpt accname non-manual', async ({ page, asset, server, browserName }) => { const injected = (window as any).__injectedScript; const title = element.getAttribute('data-testname'); const expected = element.getAttribute('data-expectedlabel'); - const received = injected.utils.getElementAccessibleName(element); + const received = injected.utils.getElementAccessibleNameText(element); result.push({ title, expected, received }); } return result; @@ -201,7 +201,7 @@ test('axe-core accessible-text', async ({ page, asset, server }) => { const element = injected.querySelector(injected.parseSelector('css=' + selector), document, false); if (!element) throw new Error(`Unable to resolve "${selector}"`); - return injected.utils.getElementAccessibleName(element); + return injected.utils.getElementAccessibleNameText(element); }); }, targets); expect.soft(received, `checking ${JSON.stringify(testCase)}`).toEqual(expected); diff --git a/tests/mcp/capabilities.spec.ts b/tests/mcp/capabilities.spec.ts index eef996256f549..e3d0c9e45aa42 100644 --- a/tests/mcp/capabilities.spec.ts +++ b/tests/mcp/capabilities.spec.ts @@ -26,6 +26,7 @@ test('test snapshot tool list', async ({ client }) => { 'browser_evaluate', 'browser_file_upload', 'browser_fill_form', + 'browser_find', 'browser_handle_dialog', 'browser_hover', 'browser_select_option', diff --git a/tests/mcp/cli-find.spec.ts b/tests/mcp/cli-find.spec.ts new file mode 100644 index 0000000000000..dafacf47aefb9 --- /dev/null +++ b/tests/mcp/cli-find.spec.ts @@ -0,0 +1,60 @@ +/** + * 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 { test, expect } from './cli-fixtures'; + +const listPage = ` +

Groceries

+
    +
  • Apples
  • +
  • Bananas
  • +
  • Cherries
  • +
+`; + +test('find by text', async ({ cli, server }) => { + server.setContent('/', listPage, 'text/html'); + await cli('open', server.PREFIX); + + const { output } = await cli('find', 'Bananas'); + expect(output).toContain('Found 1 match for "Bananas":'); + expect(output).toContain('Apples'); + expect(output).toContain('Cherries'); +}); + +test('find by regex', async ({ cli, server }) => { + server.setContent('/', listPage, 'text/html'); + await cli('open', server.PREFIX); + + const { output } = await cli('find', '--regex=Bananas|Cherries'); + expect(output).toContain('Found 2 matches for /Bananas|Cherries/:'); +}); + +test('find by regex with /i flag', async ({ cli, server }) => { + server.setContent('/', listPage, 'text/html'); + await cli('open', server.PREFIX); + + const { output } = await cli('find', '--regex=/apples/i'); + expect(output).toContain('Found 1 match for /apples/i:'); +}); + +test('find reports no matches', async ({ cli, server }) => { + server.setContent('/', listPage, 'text/html'); + await cli('open', server.PREFIX); + + const { output } = await cli('find', 'Pineapples'); + expect(output).toContain('No matches found for "Pineapples".'); +}); diff --git a/tests/mcp/core.spec.ts b/tests/mcp/core.spec.ts index 8ef418f3eb10e..b15209da6911b 100644 --- a/tests/mcp/core.spec.ts +++ b/tests/mcp/core.spec.ts @@ -109,7 +109,7 @@ test('browser_navigate can navigate to file:// URLs allowUnrestrictedFileAccess arguments: { url }, })).toHaveResponse({ page: expect.stringContaining(`- Page URL: ${url}`), - snapshot: `- generic [ref=e2]: Test file content`, + snapshot: `- generic [active] [ref=e1]: Test file content`, }); }); diff --git a/tests/mcp/find.spec.ts b/tests/mcp/find.spec.ts new file mode 100644 index 0000000000000..4455cdbcd20f0 --- /dev/null +++ b/tests/mcp/find.spec.ts @@ -0,0 +1,145 @@ +/** + * 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 { test, expect } from './fixtures'; + +const listPage = ` +

Groceries

+
    +
  • Apples
  • +
  • Bananas
  • +
  • Cherries
  • +
+ +`; + +test('browser_find by text', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + const response = await client.callTool({ + name: 'browser_find', + arguments: { text: 'Bananas' }, + }); + expect(response).toHaveResponse({ + result: expect.stringContaining(`Found 1 match for "Bananas":`), + }); + // The 3-line context window includes the neighbouring list items. + expect(response).toHaveResponse({ + result: expect.stringContaining('Apples'), + }); + expect(response).toHaveResponse({ + result: expect.stringContaining('Cherries'), + }); +}); + +test('browser_find is case-insensitive for text', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: { text: 'apples' }, + })).toHaveResponse({ + result: expect.stringContaining('Apples'), + }); +}); + +test('browser_find by regex', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: { regex: 'Bananas|Cherries' }, + })).toHaveResponse({ + result: expect.stringContaining(`Found 2 matches for /Bananas|Cherries/:`), + }); +}); + +test('browser_find regex is case-sensitive by default', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: { regex: 'apples' }, + })).toHaveResponse({ + result: `No matches found for /apples/.`, + }); +}); + +test('browser_find regex honors /i flag', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: { regex: '/apples/i' }, + })).toHaveResponse({ + result: expect.stringContaining(`Found 1 match for /apples/i:`), + }); +}); + +test('browser_find reports no matches', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: { text: 'Pineapples' }, + })).toHaveResponse({ + result: `No matches found for "Pineapples".`, + }); +}); + +test('browser_find requires text or regex', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: {}, + })).toHaveResponse({ + error: expect.stringContaining('Provide either "text" or "regex" to search for.'), + isError: true, + }); +}); + +test('browser_find rejects both text and regex', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: { text: 'Apples', regex: 'Apples' }, + })).toHaveResponse({ + error: expect.stringContaining('Provide only one of "text" or "regex", not both.'), + isError: true, + }); +}); + +test('browser_find rejects invalid regex', async ({ client, server }) => { + server.setContent('/', listPage, 'text/html'); + await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } }); + + expect(await client.callTool({ + name: 'browser_find', + arguments: { regex: '(' }, + })).toHaveResponse({ + isError: true, + }); +}); diff --git a/tests/mcp/iframes.spec.ts b/tests/mcp/iframes.spec.ts index bd2bbe46cc859..7b2c81869bf4b 100644 --- a/tests/mcp/iframes.spec.ts +++ b/tests/mcp/iframes.spec.ts @@ -26,7 +26,7 @@ test('stitched aria frames', async ({ client }) => { snapshot: expect.stringContaining(`- generic [active] [ref=e1]: - heading "Hello" [level=1] [ref=e2] - iframe [ref=e3]: - - generic [active] [ref=f1e1]: + - generic [ref=f1e1]: - button "World" [ref=f1e2] - main [ref=f1e3]: - iframe [ref=f1e4]: diff --git a/tests/page/page-aria-snapshot-ai.spec.ts b/tests/page/page-aria-snapshot-ai.spec.ts index e77b8786ea1a7..99abe6743a3fa 100644 --- a/tests/page/page-aria-snapshot-ai.spec.ts +++ b/tests/page/page-aria-snapshot-ai.spec.ts @@ -19,7 +19,7 @@ import { test as it, expect } from './pageTest'; import { unshift } from '../config/utils'; import type { Page } from 'playwright-core'; -async function snapshotForAI(page: Page, options?: Omit[0], 'mode'> & { _track?: string }): Promise { +async function snapshotForAI(page: Page, options?: Omit[0], 'mode'>): Promise { return await page.ariaSnapshot({ ...options, mode: 'ai' }); } @@ -105,13 +105,13 @@ it('should stitch all frame snapshots', async ({ page, server }) => { expect(snapshot).toContainYaml(` - generic [active] [ref=e1]: - iframe [ref=e2]: - - generic [active] [ref=f1e1]: + - generic [ref=f1e1]: - iframe [ref=f1e2]: - - generic [ref=f3e2]: Hi, I'm frame + - generic [ref=f3e1]: Hi, I'm frame - iframe [ref=f1e3]: - - generic [ref=f4e2]: Hi, I'm frame + - generic [ref=f4e1]: Hi, I'm frame - iframe [ref=e3]: - - generic [ref=f2e2]: Hi, I'm frame + - generic [ref=f2e1]: Hi, I'm frame `); const href = await page.locator('aria-ref=e1').evaluate(e => e.ownerDocument.defaultView.location.href); @@ -340,14 +340,161 @@ it('should not nest cursor pointer hints', async ({ page }) => { `); const snapshot = await snapshotForAI(page); + // The link's name is redundant - "Link with a button" prints as text and "Button" as the button - + // so it is dropped even though the node is clickable. expect(snapshot).toContainYaml(` - - link \"Link with a button Button\" [ref=e2] [cursor=pointer]: + - link [ref=e2] [cursor=pointer]: - /url: about:blank - text: Link with a button - button "Button" [ref=e3] `); }); +it('should omit names that just repeat printed descendant nodes', async ({ page }) => { + await page.setContent(` +

Clipboard API

+ `); + + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - heading [level=3] [ref=e2]: + - link "Clipboard API" [ref=e3] [cursor=pointer]: + - /url: /issues/1 + `); +}); + +it('should omit redundant name when a contributing wrapper is collapsed', async ({ page }) => { + // The flex span contributes to the heading's name, but is then removed from the tree as a + // single-child generic wrapper. Its contribution is fully represented by the link, so the + // heading's name is still redundant. + await page.setContent(` +

Clipboard API

+ `); + + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - heading [level=3] [ref=e2]: + - link "Clipboard API" [ref=e4] [cursor=pointer]: + - /url: /issues/1 + `); +}); + +it('should omit redundant name when a contributor is a skipped leaf generic', async ({ page }) => { + // The outer span is not collapsed (its child is an element, not text), so it becomes a leaf + // generic node that contributes to both names. The link's rendered name covers it, which in turn + // makes the heading's name redundant. + await page.setContent(` +

Clipboard API

+ `); + + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - heading [level=3] [ref=e2]: + - link "Clipboard API" [ref=e3] [cursor=pointer]: + - /url: /issues/1 + `); +}); + +it('should keep names not derived from printed nodes', async ({ page }) => { + await page.setContent(` +

Clipboard API

+ `); + + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - heading "Clipboard API issue" [level=3] [ref=e2]: + - link "Clipboard API" [ref=e3] [cursor=pointer]: + - /url: /issues/1 + `); +}); + +it('should omit images without an accessible name', async ({ page }) => { + await page.setContent(` + + A cat + + `); + + const snapshot = await snapshotForAI(page); + // A nameless image carries no information and is omitted, whether or not it is clickable. Only + // the named image is kept - and the body wrapper, left with a single child, is unwrapped. + expect(snapshot).toContainYaml(` + - img "A cat" [ref=e3] + `); + expect(snapshot).not.toContain('[ref=e2]'); + expect(snapshot).not.toContain('[ref=e4]'); +}); + +it('should omit a nameless image nested inside a link', async ({ page }) => { + // The decorative image has no name, so it is dropped even though it sits inside a clickable link. + await page.setContent(` + Open issue + `); + + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - link "Open issue" [ref=e2] [cursor=pointer]: + - /url: /issue/1 + `); + expect(snapshot).not.toContain('img'); +}); + +it('should omit leaf generic whose text is already in an ancestor name', async ({ page }) => { + // The inner element is block so it survives as its own generic node (an inline single-text span + // would be collapsed into the link instead). It inherits the link's pointer cursor. + await page.setContent(` +
[Feature] a dedicated clipboard API
+ `); + + const snapshot = await snapshotForAI(page); + // The link keeps its name, and the inner leaf generic that the name was computed from is dropped + // because its text is already shown by the name. + expect(snapshot).toContainYaml(` + - link "[Feature] a dedicated clipboard API" [ref=e2] [cursor=pointer]: + - /url: /issues/15860 + `); + expect(snapshot).not.toContain('[ref=e3]'); +}); + +it('should omit name-repeating generic behind a wrapper', async ({ page }) => { + // The leaf generic that repeats the link's name sits inside a nameless wrapper. Its text is + // first inlined into the wrapper, which then faces the link and removes itself. + await page.setContent(` + P3-collecting-feedback + `); + + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - link "P3-collecting-feedback" [ref=e2] [cursor=pointer]: + - /url: /labels + `); + expect(snapshot.split('P3-collecting-feedback')).toHaveLength(2); +}); + +it('should resolve refs of distilled-away nodes', async ({ page }) => { + await page.setContent(` +
[Feature] a dedicated clipboard API
+ `); + + const snapshot = await snapshotForAI(page); + // The inner leaf generic is distilled away, but its ref still resolves to the element. + expect(snapshot).not.toContain('[ref=e3]'); + await expect(page.locator('aria-ref=e3')).toHaveText('[Feature] a dedicated clipboard API'); +}); + +it('should not distill snapshots outside of ai mode', async ({ page }) => { + await page.setContent(` +

Clipboard API

+ `); + + // The heading name would be dropped as redundant in ai mode; matching mode keeps it. + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "Clipboard API" [level=3]: + - link "Clipboard API": + - /url: /issues/1 + `); +}); + it('should truncate data url in link', async ({ page }) => { const base64 = Buffer.from('

hello

').toString('base64'); await page.setContent(`a link`); @@ -376,7 +523,7 @@ it('should auto-wait for navigation', async ({ page, server }) => { snapshotForAI(page) ]); // The snapshot races the reload, which may re-number the main frame, so accept any ref. - expect(snapshot).toMatch(/- generic \[ref=(?:f\d+)?e\d+\]: Hi, I'm frame/); + expect(snapshot).toMatch(/- generic \[active\] \[ref=(?:f\d+)?e\d+\]: Hi, I'm frame/); }); it('should auto-wait for blocking CSS', async ({ page, server }) => { @@ -551,242 +698,43 @@ it('should collapse inline generic nodes', async ({ page }) => { `); }); -it('should not remove generic nodes with title', async ({ page }) => { - await page.setContent(`
Element content
`); - - const snapshot = await snapshotForAI(page); - expect(snapshot).toContainYaml(` - - generic "Element title" [ref=e2] - `); -}); - -it('should create incremental snapshots on multiple tracks', async ({ page }) => { - await page.setContent(`
  • a span
`); - - expect(await snapshotForAI(page, { _track: 'first' })).toContainYaml(` - - list [ref=e2]: - - listitem [ref=e3]: - - button "a button" [ref=e4] - - listitem [ref=e5]: a span - `); - expect(await snapshotForAI(page, { _track: 'second' })).toContainYaml(` - - list [ref=e2]: - - listitem [ref=e3]: - - button "a button" [ref=e4] - - listitem [ref=e5]: a span - `); - expect(await snapshotForAI(page, { _track: 'first' })).toContainYaml(` - `); - - await page.evaluate(() => { - document.querySelector('span').textContent = 'changed span'; - document.getElementById('hidden-li').style.display = 'inline'; - }); - expect(await snapshotForAI(page, { _track: 'first' })).toContainYaml(` - - list [ref=e2]: - - ref=e3 [unchanged] - - listitem [ref=e5]: changed span - - listitem [ref=e6]: some text - `); - - await page.evaluate(() => { - document.querySelector('span').textContent = 'a span'; - document.getElementById('hidden-li').style.display = 'none'; - }); - expect(await snapshotForAI(page, { _track: 'first' })).toContainYaml(` - - list [ref=e2]: - - ref=e3 [unchanged] - - listitem [ref=e5]: a span - `); - expect(await snapshotForAI(page, { _track: 'second' })).toContainYaml(` - `); -}); - -it('should create incremental snapshot for attribute change', async ({ page }) => { - await page.setContent(``); - await page.evaluate(() => document.querySelector('button').focus()); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - button "a button" [active] [ref=e2] - `); - - await page.evaluate(() => document.querySelector('button').blur()); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - button "a button" [ref=e2] - `); -}); - -it('should create incremental snapshot for child removal', async ({ page }) => { - await page.setContent(`
  • some text
  • `); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=e2]: - - button "a button" [ref=e3] - - text: some text - `); - - await page.evaluate(() => document.querySelector('span').remove()); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=e2]: - - ref=e3 [unchanged] - `); -}); - -it('should create incremental snapshot for child addition', async ({ page }) => { - await page.setContent(`
  • some text
  • `); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=e2]: - - button "a button" [ref=e3] - `); - - await page.evaluate(() => document.querySelector('span').style.display = 'inline'); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=e2]: - - ref=e3 [unchanged] - - text: some text - `); -}); - -it('should create incremental snapshot for prop change', async ({ page }) => { - await page.setContent(`a link`); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - link "a link" [ref=e2] [cursor=pointer]: - - /url: about:blank - `); - - await page.evaluate(() => document.querySelector('a').setAttribute('href', 'https://playwright.dev')); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - link "a link" [ref=e2] [cursor=pointer]: - - /url: https://playwright.dev - `); -}); - -it('should create incremental snapshot for cursor change', async ({ page }) => { - await page.setContent(`a link`); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - link "a link" [ref=e2] [cursor=pointer]: - - /url: about:blank - `); - - await page.evaluate(() => document.querySelector('a').style.cursor = 'default'); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - link "a link" [ref=e2]: - - /url: about:blank - `); -}); - -it('should create incremental snapshot for name change', async ({ page }) => { - await page.setContent(``); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - button "a button" [ref=e2] - `); - - await page.evaluate(() => document.querySelector('span').textContent = 'new button'); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - button "new button" [ref=e3] - `); -}); - -it('should create incremental snapshot for text change', async ({ page }) => { - await page.setContent(`
  • an item
  • `); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=e2]: an item - `); - - await page.evaluate(() => document.querySelector('span').textContent = 'new text'); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=e2]: new text - `); -}); - -it('should produce incremental snapshot for iframes', async ({ page }) => { +it('should inline single leaf generic child into parent generic', async ({ page }) => { + // The nameless images are distilled away, so each wrapper is left with a single leaf generic + // child, whose text is inlined into the wrapper - recursively for the second one. await page.setContent(` - - `); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - iframe [ref=e2]: - - listitem [ref=f1e2]: - - button "a button" [ref=f1e3] +
    Status: Open.
    +
    Nested twice.
    `); - await page.frames()[1].evaluate(() => { - document.querySelector('span').style.display = 'block'; - document.querySelector('iframe').style.display = 'block'; - }); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=f1e2]: - - generic [ref=f1e4]: outer text - - ref=f1e3 [unchanged] - - iframe [ref=f1e5] - - iframe [ref=f1e5]: - - listitem [ref=f2e2]: inner text + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - generic [active] [ref=e1]: + - generic [ref=e2]: "Status: Open." + - generic [ref=e5]: Nested twice. `); }); -it('should create multiple chunks in incremental snapshot', async ({ page }) => { +it('should inline a deeply nested generic', async ({ page }) => { + // Every wrapper contains a nameless image (distilled away) plus a single generic child, so the + // text bubbles up the whole chain - all the way into the body. + const img = ``; await page.setContent(` -
      -
    • item1
    • -
    • item2
    • -
    • item3
    • -
        -
      • to be removed
      • -
      • one more
      • -
      -
    - `); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - list [ref=e2]: - - listitem [ref=e3]: item1 - - listitem [ref=e4]: item2 - - listitem [ref=e5]: - - group [ref=e6]: item3 - - list [ref=e7]: - - listitem [ref=e8]: to be removed - - listitem [ref=e9]: one more +
    ${img}
    ${img}
    ${img}
    ${img}
    Deeply nested.
    `); - await page.evaluate(() => { - const spans = document.querySelectorAll('span'); - spans[0].textContent = 'new item1'; - spans[2].textContent = 'new item3'; - const button = document.createElement('button'); - button.textContent = 'button'; - spans[2].parentElement.appendChild(button); - document.querySelector('#to-remove').remove(); - }); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - listitem [ref=e3]: new item1 - - group [ref=e6]: - - text: new item3 - - button "button" [ref=e10] - - list [ref=e7]: - - ref=e9 [unchanged] + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - generic [active] [ref=e1]: Deeply nested. `); + expect(snapshot).not.toContain('img'); }); -it('should create incremental snapshot for children swap', async ({ page }) => { - await page.setContent(` -
      -
    • item 1
    • -
    • item 2
    • -
    - `); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - list [ref=e2]: - - listitem [ref=e3]: item 1 - - listitem [ref=e4]: item 2 - `); +it('should not remove generic nodes with title', async ({ page }) => { + await page.setContent(`
    Element content
    `); - await page.evaluate(() => document.querySelector('ul').appendChild(document.querySelector('li'))); - expect(await snapshotForAI(page, { _track: 'track' })).toContainYaml(` - - list [ref=e2]: - - ref=e4 [unchanged] - - ref=e3 [unchanged] + const snapshot = await snapshotForAI(page); + expect(snapshot).toContainYaml(` + - generic "Element title" [ref=e2] `); }); diff --git a/tests/page/page-click.spec.ts b/tests/page/page-click.spec.ts index bfe0691e5cf4d..8649e738ac1e8 100644 --- a/tests/page/page-click.spec.ts +++ b/tests/page/page-click.spec.ts @@ -1215,18 +1215,13 @@ it('should fire contextmenu event on right click in correct order', async ({ pag const entries = []; page.on('console', message => entries.push(message.text())); await page.getByRole('button', { name: 'Click me' }).click({ button: 'right' }); - if (browserName === 'webkit') - await expect.poll(() => entries).toEqual(['mousedown', 'contextmenu']); - else if (browserName === 'chromium' && isWindows) + if (browserName === 'chromium' && isWindows) await expect.poll(() => entries).toEqual(['mousedown', 'mouseup', 'contextmenu']); else await expect.poll(() => entries).toEqual(['mousedown', 'contextmenu', 'mouseup']); }); -it('should click after a right click', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/39246' } }, async ({ page, browserName }) => { - // On webkit the native context menu opened by the right click swallows the - // following left click, so the button never receives it. - it.fixme(browserName === 'webkit'); +it('should click after a right click', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/39246' } }, async ({ page }) => { await page.setContent(`