diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index f0231e9551..96399512ee 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -20,6 +20,7 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { InjectedScript } from './injectedScript'; +import { generateSimpleDom } from './simpleDom'; const selectorSymbol = Symbol('selector'); @@ -85,6 +86,7 @@ class ConsoleAPI { inspect: (selector: string) => this._inspect(selector), selector: (element: Element) => this._selector(element), generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language), + _snapshot: () => generateSimpleDom(this._injectedScript).markup, resume: () => this._resume(), ...new Locator(injectedScript, ''), }; diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 69fe959f81..90ee835fae 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -29,12 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; -import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches } from './roleUtils'; +import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches, getAriaLevel, getAriaChecked } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; -import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom'; +import { generateSimpleDomNode } from './simpleDom'; import type { SimpleDomNode } from './simpleDom'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -81,11 +81,14 @@ export class InjectedScript { escapeHTML, escapeHTMLAttribute, getAriaRole, + getAriaLevel, + getAriaChecked, getElementAccessibleDescription, getElementAccessibleName, isElementVisible, isInsideScope, normalizeWhiteSpace, + autoClosingTags, }; // eslint-disable-next-line no-restricted-globals @@ -1319,10 +1322,6 @@ export class InjectedScript { return; return generateSimpleDomNode(this, element); } - - selectorForSimpleDomNodeId(nodeId: string) { - return selectorForSimpleDomNodeId(this, nodeId); - } } const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); diff --git a/packages/playwright-core/src/server/injected/simpleDom.ts b/packages/playwright-core/src/server/injected/simpleDom.ts index c31862cd6c..cef6461f0e 100644 --- a/packages/playwright-core/src/server/injected/simpleDom.ts +++ b/packages/playwright-core/src/server/injected/simpleDom.ts @@ -16,14 +16,6 @@ import type { InjectedScript } from './injectedScript'; -const leafRoles = new Set([ - 'button', - 'checkbox', - 'combobox', - 'link', - 'textbox', -]); - export type SimpleDom = { markup: string; elements: Map; @@ -35,24 +27,15 @@ export type SimpleDomNode = { tag: string; }; -let lastDom: SimpleDom | undefined; - export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom { return generate(injectedScript).dom; } export function generateSimpleDomNode(injectedScript: InjectedScript, target: Element): SimpleDomNode { - return generate(injectedScript, target).node!; + return generate(injectedScript, { target }).node!; } -export function selectorForSimpleDomNodeId(injectedScript: InjectedScript, id: string): string { - const element = lastDom?.elements.get(id); - if (!element) - throw new Error(`Internal error: element with id "${id}" not found`); - return injectedScript.generateSelectorSimple(element); -} - -function generate(injectedScript: InjectedScript, target?: Element): { dom: SimpleDom, node?: SimpleDomNode } { +function generate(injectedScript: InjectedScript, options?: { target?: Element, generateIds?: boolean }): { dom: SimpleDom, node?: SimpleDomNode } { const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' '); const tokens: string[] = []; const elements = new Map(); @@ -64,30 +47,53 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp return; } - if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as Element; - if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT') - return; - if (injectedScript.utils.isElementVisible(element)) { - const role = injectedScript.utils.getAriaRole(element) as string; - if (role && leafRoles.has(role)) { - let value: string | undefined; - if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') - value = (element as HTMLInputElement | HTMLTextAreaElement).value; - const name = injectedScript.utils.getElementAccessibleName(element, false); - const structuralId = String(++lastId); - elements.set(structuralId, element); - tokens.push(renderTag(injectedScript, role, name, structuralId, { value })); - if (element === target) { - const tagNoValue = renderTag(injectedScript, role, name, structuralId); - resultTarget = { tag: tagNoValue, id: structuralId }; - } - return; - } - } + if (node.nodeType !== Node.ELEMENT_NODE) + return; + + const element = node as Element; + if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT') + return; + + const isElementVisible = injectedScript.utils.isElementVisible(element); + const hasVisibleChildren = isElementVisible && element.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true }); + + if (!hasVisibleChildren) + return; + + if (!isElementVisible) { for (let child = element.firstChild; child; child = child.nextSibling) visit(child); + return; } + + const role = injectedScript.utils.getAriaRole(element) as string; + if (role && leafRoles.has(role)) { + const structuralId = options?.generateIds ? String(++lastId) : undefined; + if (structuralId) + elements.set(structuralId, element); + + const tag = roleToTag(injectedScript, role, element); + tokens.push(renderLeafTag(injectedScript, tag, structuralId)); + if (element === options?.target) { + if (tag.attributes) + delete tag.attributes.value; + const tagNoValue = renderLeafTag(injectedScript, tag, structuralId); + resultTarget = { tag: tagNoValue, id: structuralId! }; + } + return; + } + + let compositeTag: Tag | undefined; + if (role) { + compositeTag = roleToTag(injectedScript, role, element); + tokens.push(renderOpeningTag(injectedScript, compositeTag)); + } + + for (let child = element.firstChild; child; child = child.nextSibling) + visit(child); + + if (compositeTag) + tokens.push(renderClosingTag(compositeTag)); }; injectedScript.utils.beginAriaCaches(); try { @@ -100,21 +106,127 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp elements }; - if (target && !resultTarget) + if (options?.target && !resultTarget) throw new Error('Target element is not in the simple DOM'); - lastDom = dom; - return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined }; } -function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string { - const escapedTextContent = injectedScript.utils.escapeHTML(name); - const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || ''); +const leafRoles = new Set([ + 'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader', + 'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion', + 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'none', 'option', + 'presentation', 'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator', + 'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term', + 'textbox', 'time', 'tooltip' +]); + +type Tag = { + tagName: string; + content?: string; + attributes?: Record; +}; + +function roleToTag(injectedScript: InjectedScript, role: string, element: Element): Tag { + const accessibleName = injectedScript.utils.getElementAccessibleName(element, false); + let value = ''; + if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') + value = (element as HTMLInputElement | HTMLTextAreaElement).value; + switch (role) { - case 'button': return ``; - case 'link': return `${escapedTextContent}`; - case 'textbox': return ``; + case 'article': return { tagName: 'ARTICLE' }; + case 'banner': return { tagName: 'HEADER' }; + case 'blockquote': return { tagName: 'BLOCKQUOTE' }; + case 'button': return { tagName: 'BUTTON', content: accessibleName }; + case 'caption': return { tagName: 'CAPTION' }; + case 'cell': return { tagName: 'TD' }; + case 'checkbox': { + const attributes: Record = { type: 'checkbox' }; + if (injectedScript.utils.getAriaChecked(element)) + attributes.checked = ''; + return { tagName: 'INPUT', attributes }; + } + case 'code': return { tagName: 'CODE' }; + case 'columnheader': return { tagName: 'TH', attributes: { scope: 'col' } }; + case 'combobox': return { tagName: 'SELECT' }; + case 'complementary': return { tagName: 'ASIDE' }; + case 'contentinfo': return { tagName: 'FOOTER' }; + case 'definition': return { tagName: 'DD' }; + case 'dialog': return { tagName: 'DIALOG' }; + case 'document': return { tagName: 'HTML' }; + case 'emphasis': return { tagName: 'EM' }; + case 'figure': return { tagName: 'FIGURE' }; + case 'form': return { tagName: 'FORM' }; + case 'gridcell': return { tagName: 'TD' }; + case 'group': return { tagName: 'OPTGROUP' }; + case 'heading': { + const level = injectedScript.utils.getAriaLevel(element); + return { tagName: `H${level}`, content: element.textContent || '' }; + } + case 'img': return { tagName: 'IMG', attributes: { alt: accessibleName } }; + case 'insertion': return { tagName: 'INS' }; + case 'link': return { tagName: 'A', content: accessibleName }; + case 'list': return { tagName: 'UL' }; + case 'listbox': return { tagName: 'SELECT', attributes: { 'multiple': '' } }; + case 'listitem': return { tagName: 'LI' }; + case 'main': return { tagName: 'MAIN' }; + case 'mark': return { tagName: 'MARK' }; + case 'math': return { tagName: 'MATH' }; + case 'meter': return { tagName: 'METER' }; + case 'navigation': return { tagName: 'NAV' }; + case 'option': return { tagName: 'OPTION', content: accessibleName }; + case 'paragraph': return { tagName: 'P', content: element.textContent || '' }; + case 'progressbar': return { tagName: 'PROGRESS' }; + case 'radio': { + const attributes: Record = { type: 'radio' }; + if (injectedScript.utils.getAriaChecked(element)) + attributes.checked = ''; + return { tagName: 'INPUT', attributes }; + } + case 'region': return { tagName: 'SECTION' }; + case 'row': return { tagName: 'TR' }; + case 'rowgroup': return { tagName: 'TBODY' }; + case 'rowheader': return { tagName: 'TH', attributes: { scope: 'row' } }; + case 'searchbox': return { tagName: 'INPUT', attributes: { type: 'search' } }; + case 'separator': return { tagName: 'HR' }; + case 'slider': return { tagName: 'INPUT', attributes: { type: 'range' } }; + case 'spinbutton': return { tagName: 'INPUT', attributes: { type: 'number' } }; + case 'status': return { tagName: 'OUTPUT' }; + case 'strong': return { tagName: 'STRONG' }; + case 'submit': return { tagName: 'INPUT', attributes: { type: 'submit' } }; + case 'subscript': return { tagName: 'SUB' }; + case 'superscript': return { tagName: 'SUP' }; + case 'table': return { tagName: 'TABLE' }; + case 'term': return { tagName: 'DT' }; + case 'textbox': return { tagName: 'INPUT', attributes: { type: 'text', value } }; + case 'time': return { tagName: 'TIME' }; } - return `
${escapedTextContent}
`; + return { tagName: 'DIV', attributes: { role, 'aria-label': accessibleName } }; +} + +function renderOpeningTag(injectedScript: InjectedScript, tag: Tag) { + const result: string[] = []; + result.push(`<${tag.tagName.toLowerCase()}`); + for (const [name, value] of Object.entries(tag.attributes || {})) { + const valueText = value ? `="${injectedScript.utils.escapeHTMLAttribute(value)}"` : ''; + result.push(` ${name}${valueText}`); + } + result.push('>'); + return result.join(''); +} + +function renderClosingTag(tag: Tag) { + return ``; +} + +function renderLeafTag(injectedScript: InjectedScript, tag: Tag, id?: string) { + if (id) { + tag.attributes = tag.attributes || {}; + tag.attributes['id'] = id; + } + + if (injectedScript.utils.autoClosingTags.has(tag.tagName)) + return renderOpeningTag(injectedScript, tag); + + return renderOpeningTag(injectedScript, tag) + (injectedScript.utils.escapeHTML(tag.content || '')) + renderClosingTag(tag); }