chore: make simple dom more complete
This commit is contained in:
parent
6f16b6cc08
commit
1c02533322
|
|
@ -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, ''),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<channels.FrameExpectParams, 'expectedValue'> & { 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']);
|
||||
|
|
|
|||
|
|
@ -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<string, Element>;
|
||||
|
|
@ -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<string, Element>();
|
||||
|
|
@ -64,30 +47,53 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp
|
|||
return;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||
return;
|
||||
|
||||
const element = node as Element;
|
||||
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
|
||||
return;
|
||||
if (injectedScript.utils.isElementVisible(element)) {
|
||||
|
||||
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)) {
|
||||
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);
|
||||
const structuralId = options?.generateIds ? String(++lastId) : undefined;
|
||||
if (structuralId)
|
||||
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 };
|
||||
|
||||
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<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) {
|
||||
case 'button': return `<button id="${id}">${escapedTextContent}</button>`;
|
||||
case 'link': return `<a id="${id}">${escapedTextContent}</a>`;
|
||||
case 'textbox': return `<input id="${id}" title="${escapedTextContent}" value="${escapedValue}"></input>`;
|
||||
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<string, string> = { type: 'checkbox' };
|
||||
if (injectedScript.utils.getAriaChecked(element))
|
||||
attributes.checked = '';
|
||||
return { tagName: 'INPUT', attributes };
|
||||
}
|
||||
return `<div role=${role} id="${id}">${escapedTextContent}</div>`;
|
||||
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 { 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue