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 { 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, ''),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
return;
|
||||||
|
|
||||||
const element = node as Element;
|
const element = node as Element;
|
||||||
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
|
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
|
||||||
return;
|
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;
|
const role = injectedScript.utils.getAriaRole(element) as string;
|
||||||
if (role && leafRoles.has(role)) {
|
if (role && leafRoles.has(role)) {
|
||||||
let value: string | undefined;
|
const structuralId = options?.generateIds ? String(++lastId) : undefined;
|
||||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
|
if (structuralId)
|
||||||
value = (element as HTMLInputElement | HTMLTextAreaElement).value;
|
|
||||||
const name = injectedScript.utils.getElementAccessibleName(element, false);
|
|
||||||
const structuralId = String(++lastId);
|
|
||||||
elements.set(structuralId, element);
|
elements.set(structuralId, element);
|
||||||
tokens.push(renderTag(injectedScript, role, name, structuralId, { value }));
|
|
||||||
if (element === target) {
|
const tag = roleToTag(injectedScript, role, element);
|
||||||
const tagNoValue = renderTag(injectedScript, role, name, structuralId);
|
tokens.push(renderLeafTag(injectedScript, tag, structuralId));
|
||||||
resultTarget = { tag: tagNoValue, id: structuralId };
|
if (element === options?.target) {
|
||||||
|
if (tag.attributes)
|
||||||
|
delete tag.attributes.value;
|
||||||
|
const tagNoValue = renderLeafTag(injectedScript, tag, structuralId);
|
||||||
|
resultTarget = { tag: tagNoValue, id: structuralId! };
|
||||||
}
|
}
|
||||||
return;
|
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)
|
for (let child = element.firstChild; child; child = child.nextSibling)
|
||||||
visit(child);
|
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 };
|
||||||
}
|
}
|
||||||
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