chore: make simple dom more complete

This commit is contained in:
Pavel Feldman 2024-10-01 15:55:55 -07:00
parent 6f16b6cc08
commit 1c02533322
3 changed files with 169 additions and 56 deletions

View file

@ -20,6 +20,7 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators';
import type { InjectedScript } from './injectedScript'; import type { InjectedScript } from './injectedScript';
import { generateSimpleDom } from './simpleDom';
const selectorSymbol = Symbol('selector'); const selectorSymbol = Symbol('selector');
@ -85,6 +86,7 @@ class ConsoleAPI {
inspect: (selector: string) => this._inspect(selector), inspect: (selector: string) => this._inspect(selector),
selector: (element: Element) => this._selector(element), selector: (element: Element) => this._selector(element),
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language), generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
_snapshot: () => generateSimpleDom(this._injectedScript).markup,
resume: () => this._resume(), resume: () => this._resume(),
...new Locator(injectedScript, ''), ...new Locator(injectedScript, ''),
}; };

View file

@ -29,12 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Highlight } from './highlight'; 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 { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators';
import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom'; import { generateSimpleDomNode } from './simpleDom';
import type { SimpleDomNode } from './simpleDom'; import type { SimpleDomNode } from './simpleDom';
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any }; export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
@ -81,11 +81,14 @@ export class InjectedScript {
escapeHTML, escapeHTML,
escapeHTMLAttribute, escapeHTMLAttribute,
getAriaRole, getAriaRole,
getAriaLevel,
getAriaChecked,
getElementAccessibleDescription, getElementAccessibleDescription,
getElementAccessibleName, getElementAccessibleName,
isElementVisible, isElementVisible,
isInsideScope, isInsideScope,
normalizeWhiteSpace, normalizeWhiteSpace,
autoClosingTags,
}; };
// eslint-disable-next-line no-restricted-globals // eslint-disable-next-line no-restricted-globals
@ -1319,10 +1322,6 @@ export class InjectedScript {
return; return;
return generateSimpleDomNode(this, element); 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']); const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);

View file

@ -16,14 +16,6 @@
import type { InjectedScript } from './injectedScript'; import type { InjectedScript } from './injectedScript';
const leafRoles = new Set([
'button',
'checkbox',
'combobox',
'link',
'textbox',
]);
export type SimpleDom = { export type SimpleDom = {
markup: string; markup: string;
elements: Map<string, Element>; elements: Map<string, Element>;
@ -35,24 +27,15 @@ export type SimpleDomNode = {
tag: string; tag: string;
}; };
let lastDom: SimpleDom | undefined;
export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom { export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom {
return generate(injectedScript).dom; return generate(injectedScript).dom;
} }
export function generateSimpleDomNode(injectedScript: InjectedScript, target: Element): SimpleDomNode { 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 { function generate(injectedScript: InjectedScript, options?: { target?: Element, generateIds?: boolean }): { dom: SimpleDom, node?: SimpleDomNode } {
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 } {
const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' '); const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' ');
const tokens: string[] = []; const tokens: string[] = [];
const elements = new Map<string, Element>(); const elements = new Map<string, Element>();
@ -64,30 +47,53 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp
return; return;
} }
if (node.nodeType === Node.ELEMENT_NODE) { if (node.nodeType !== Node.ELEMENT_NODE)
const element = node as Element; return;
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
return; const element = node as Element;
if (injectedScript.utils.isElementVisible(element)) { if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
const role = injectedScript.utils.getAriaRole(element) as string; return;
if (role && leafRoles.has(role)) {
let value: string | undefined; const isElementVisible = injectedScript.utils.isElementVisible(element);
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') const hasVisibleChildren = isElementVisible && element.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true });
value = (element as HTMLInputElement | HTMLTextAreaElement).value;
const name = injectedScript.utils.getElementAccessibleName(element, false); if (!hasVisibleChildren)
const structuralId = String(++lastId); return;
elements.set(structuralId, element);
tokens.push(renderTag(injectedScript, role, name, structuralId, { value })); if (!isElementVisible) {
if (element === target) {
const tagNoValue = renderTag(injectedScript, role, name, structuralId);
resultTarget = { tag: tagNoValue, id: structuralId };
}
return;
}
}
for (let child = element.firstChild; child; child = child.nextSibling) for (let child = element.firstChild; child; child = child.nextSibling)
visit(child); 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(); injectedScript.utils.beginAriaCaches();
try { try {
@ -100,21 +106,127 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp
elements elements
}; };
if (target && !resultTarget) if (options?.target && !resultTarget)
throw new Error('Target element is not in the simple DOM'); throw new Error('Target element is not in the simple DOM');
lastDom = dom;
return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined }; return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined };
} }
function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string { const leafRoles = new Set([
const escapedTextContent = injectedScript.utils.escapeHTML(name); 'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || ''); '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<string, string>;
};
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) { switch (role) {
case 'button': return `<button id="${id}">${escapedTextContent}</button>`; case 'article': return { tagName: 'ARTICLE' };
case 'link': return `<a id="${id}">${escapedTextContent}</a>`; case 'banner': return { tagName: 'HEADER' };
case 'textbox': return `<input id="${id}" title="${escapedTextContent}" value="${escapedValue}"></input>`; 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<string, string> = { 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<string, string> = { 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 `<div role=${role} id="${id}">${escapedTextContent}</div>`; 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 `</${tag.tagName.toLowerCase()}>`;
}
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);
} }