feat(codegen): improve selector generation (#5364)
- Snap to buttons, inputs, selects, etc. - Try `<label>` selector in addition to the element. - Use parent selectors when needed. - Remove xpath fallback as it should be covered with css.
This commit is contained in:
parent
b50c363bba
commit
0871a9cfd9
|
|
@ -40,7 +40,7 @@ export type InjectedScriptPoll<T> = {
|
||||||
|
|
||||||
export class InjectedScript {
|
export class InjectedScript {
|
||||||
private _enginesV1: Map<string, SelectorEngine>;
|
private _enginesV1: Map<string, SelectorEngine>;
|
||||||
private _evaluator: SelectorEvaluatorImpl;
|
_evaluator: SelectorEvaluatorImpl;
|
||||||
|
|
||||||
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
|
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
|
||||||
this._enginesV1 = new Map();
|
this._enginesV1 = new Map();
|
||||||
|
|
@ -75,9 +75,12 @@ export class InjectedScript {
|
||||||
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
|
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
|
||||||
if (!(root as any)['querySelector'])
|
if (!(root as any)['querySelector'])
|
||||||
throw new Error('Node is not queryable.');
|
throw new Error('Node is not queryable.');
|
||||||
const result = this._querySelectorRecursively(root as SelectorRoot, selector, 0);
|
this._evaluator.begin();
|
||||||
this._evaluator.clearCaches();
|
try {
|
||||||
return result;
|
return this._querySelectorRecursively(root as SelectorRoot, selector, 0);
|
||||||
|
} finally {
|
||||||
|
this._evaluator.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined {
|
private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined {
|
||||||
|
|
@ -95,30 +98,34 @@ export class InjectedScript {
|
||||||
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
||||||
if (!(root as any)['querySelectorAll'])
|
if (!(root as any)['querySelectorAll'])
|
||||||
throw new Error('Node is not queryable.');
|
throw new Error('Node is not queryable.');
|
||||||
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
|
this._evaluator.begin();
|
||||||
// Query all elements up to the capture.
|
try {
|
||||||
const partsToQueryAll = selector.parts.slice(0, capture + 1);
|
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
|
||||||
// Check they have a descendant matching everything after the capture.
|
// Query all elements up to the capture.
|
||||||
const partsToCheckOne = selector.parts.slice(capture + 1);
|
const partsToQueryAll = selector.parts.slice(0, capture + 1);
|
||||||
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
|
// Check they have a descendant matching everything after the capture.
|
||||||
for (const part of partsToQueryAll) {
|
const partsToCheckOne = selector.parts.slice(capture + 1);
|
||||||
const newSet = new Set<Element>();
|
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
|
||||||
for (const prev of set) {
|
for (const part of partsToQueryAll) {
|
||||||
for (const next of this._queryEngineAll(part, prev)) {
|
const newSet = new Set<Element>();
|
||||||
if (newSet.has(next))
|
for (const prev of set) {
|
||||||
continue;
|
for (const next of this._queryEngineAll(part, prev)) {
|
||||||
newSet.add(next);
|
if (newSet.has(next))
|
||||||
|
continue;
|
||||||
|
newSet.add(next);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
set = newSet;
|
||||||
}
|
}
|
||||||
set = newSet;
|
let result = Array.from(set) as Element[];
|
||||||
|
if (partsToCheckOne.length) {
|
||||||
|
const partial = { parts: partsToCheckOne };
|
||||||
|
result = result.filter(e => !!this._querySelectorRecursively(e, partial, 0));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this._evaluator.end();
|
||||||
}
|
}
|
||||||
let result = Array.from(set) as Element[];
|
|
||||||
if (partsToCheckOne.length) {
|
|
||||||
const partial = { parts: partsToCheckOne };
|
|
||||||
result = result.filter(e => !!this._querySelectorRecursively(e, partial, 0));
|
|
||||||
}
|
|
||||||
this._evaluator.clearCaches();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _queryEngine(part: ParsedSelectorPart, root: SelectorRoot): Element | undefined {
|
private _queryEngine(part: ParsedSelectorPart, root: SelectorRoot): Element | undefined {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
private _cacheQuerySimple: QueryCache = new Map();
|
private _cacheQuerySimple: QueryCache = new Map();
|
||||||
_cacheText = new Map<Element | ShadowRoot, string>();
|
_cacheText = new Map<Element | ShadowRoot, string>();
|
||||||
private _scoreMap: Map<Element, number> | undefined;
|
private _scoreMap: Map<Element, number> | undefined;
|
||||||
|
private _retainCacheCounter = 0;
|
||||||
|
|
||||||
constructor(extraEngines: Map<string, SelectorEngine>) {
|
constructor(extraEngines: Map<string, SelectorEngine>) {
|
||||||
for (const [name, engine] of extraEngines)
|
for (const [name, engine] of extraEngines)
|
||||||
|
|
@ -75,16 +76,23 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
throw new Error(`Please keep customCSSNames in sync with evaluator engines`);
|
throw new Error(`Please keep customCSSNames in sync with evaluator engines`);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCaches() {
|
begin() {
|
||||||
this._cacheQueryCSS.clear();
|
++this._retainCacheCounter;
|
||||||
this._cacheMatches.clear();
|
}
|
||||||
this._cacheQuery.clear();
|
|
||||||
this._cacheMatchesSimple.clear();
|
end() {
|
||||||
this._cacheMatchesParents.clear();
|
--this._retainCacheCounter;
|
||||||
this._cacheCallMatches.clear();
|
if (!this._retainCacheCounter) {
|
||||||
this._cacheCallQuery.clear();
|
this._cacheQueryCSS.clear();
|
||||||
this._cacheQuerySimple.clear();
|
this._cacheMatches.clear();
|
||||||
this._cacheText.clear();
|
this._cacheQuery.clear();
|
||||||
|
this._cacheMatchesSimple.clear();
|
||||||
|
this._cacheMatchesParents.clear();
|
||||||
|
this._cacheCallMatches.clear();
|
||||||
|
this._cacheCallQuery.clear();
|
||||||
|
this._cacheQuerySimple.clear();
|
||||||
|
this._cacheText.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _cached<T>(cache: QueryCache, main: any, rest: any[], cb: () => T): T {
|
private _cached<T>(cache: QueryCache, main: any, rest: any[], cb: () => T): T {
|
||||||
|
|
@ -109,43 +117,53 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
|
|
||||||
matches(element: Element, s: Selector, context: QueryContext): boolean {
|
matches(element: Element, s: Selector, context: QueryContext): boolean {
|
||||||
const selector = this._checkSelector(s);
|
const selector = this._checkSelector(s);
|
||||||
return this._cached<boolean>(this._cacheMatches, element, [selector, context], () => {
|
this.begin();
|
||||||
if (Array.isArray(selector))
|
try {
|
||||||
return this._matchesEngine(isEngine, element, selector, context);
|
return this._cached<boolean>(this._cacheMatches, element, [selector, context], () => {
|
||||||
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
|
if (Array.isArray(selector))
|
||||||
return false;
|
return this._matchesEngine(isEngine, element, selector, context);
|
||||||
return this._matchesParents(element, selector, selector.simples.length - 2, context);
|
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
|
||||||
});
|
return false;
|
||||||
|
return this._matchesParents(element, selector, selector.simples.length - 2, context);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query(context: QueryContext, s: any): Element[] {
|
query(context: QueryContext, s: any): Element[] {
|
||||||
const selector = this._checkSelector(s);
|
const selector = this._checkSelector(s);
|
||||||
return this._cached<Element[]>(this._cacheQuery, selector, [context], () => {
|
this.begin();
|
||||||
if (Array.isArray(selector))
|
try {
|
||||||
return this._queryEngine(isEngine, context, selector);
|
return this._cached<Element[]>(this._cacheQuery, selector, [context], () => {
|
||||||
|
if (Array.isArray(selector))
|
||||||
|
return this._queryEngine(isEngine, context, selector);
|
||||||
|
|
||||||
// query() recursively calls itself, so we set up a new map for this particular query() call.
|
// query() recursively calls itself, so we set up a new map for this particular query() call.
|
||||||
const previousScoreMap = this._scoreMap;
|
const previousScoreMap = this._scoreMap;
|
||||||
this._scoreMap = new Map();
|
this._scoreMap = new Map();
|
||||||
let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector);
|
let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector);
|
||||||
elements = elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context));
|
elements = elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context));
|
||||||
if (this._scoreMap.size) {
|
if (this._scoreMap.size) {
|
||||||
elements.sort((a, b) => {
|
elements.sort((a, b) => {
|
||||||
const aScore = this._scoreMap!.get(a);
|
const aScore = this._scoreMap!.get(a);
|
||||||
const bScore = this._scoreMap!.get(b);
|
const bScore = this._scoreMap!.get(b);
|
||||||
if (aScore === bScore)
|
if (aScore === bScore)
|
||||||
return 0;
|
return 0;
|
||||||
if (aScore === undefined)
|
if (aScore === undefined)
|
||||||
return 1;
|
return 1;
|
||||||
if (bScore === undefined)
|
if (bScore === undefined)
|
||||||
return -1;
|
return -1;
|
||||||
return aScore - bScore;
|
return aScore - bScore;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._scoreMap = previousScoreMap;
|
this._scoreMap = previousScoreMap;
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
this.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_markScore(element: Element, score: number) {
|
_markScore(element: Element, score: number) {
|
||||||
|
|
@ -458,7 +476,7 @@ function shouldSkipForTextMatching(element: Element | ShadowRoot) {
|
||||||
return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
|
return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
function elementText(evaluator: SelectorEvaluatorImpl, root: Element | ShadowRoot): string {
|
export function elementText(evaluator: SelectorEvaluatorImpl, root: Element | ShadowRoot): string {
|
||||||
let value = evaluator._cacheText.get(root);
|
let value = evaluator._cacheText.get(root);
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
value = '';
|
value = '';
|
||||||
|
|
|
||||||
|
|
@ -15,74 +15,150 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type InjectedScript from '../../injected/injectedScript';
|
import type InjectedScript from '../../injected/injectedScript';
|
||||||
|
import { elementText } from '../../injected/selectorEvaluator';
|
||||||
|
|
||||||
|
type SelectorToken = {
|
||||||
|
engine: string;
|
||||||
|
selector: string;
|
||||||
|
score: number; // Lower is better.
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheAllowText = new Map<Element, SelectorToken[] | null>();
|
||||||
|
const cacheDisallowText = new Map<Element, SelectorToken[] | null>();
|
||||||
|
|
||||||
export function generateSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } {
|
export function generateSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } {
|
||||||
const path: SelectorToken[] = [];
|
injectedScript._evaluator.begin();
|
||||||
let numberOfMatchingElements = Number.MAX_SAFE_INTEGER;
|
try {
|
||||||
for (let element: Element | null = targetElement; element && element !== document.documentElement; element = parentElementOrShadowHost(element)) {
|
targetElement = targetElement.closest('button,select,input,[role=button],[role=checkbox],[role=radio]') || targetElement;
|
||||||
const selector = buildSelectorCandidate(element);
|
let bestTokens = generateSelectorFor(injectedScript, targetElement);
|
||||||
if (!selector)
|
|
||||||
continue;
|
const targetLabel = findTargetLabel(targetElement);
|
||||||
const fullSelector = joinSelector([selector, ...path]);
|
const labelTokens = targetLabel ? generateSelectorFor(injectedScript, targetLabel) : null;
|
||||||
const parsedSelector = injectedScript.parseSelector(fullSelector);
|
if (labelTokens && combineScores(labelTokens) < combineScores(bestTokens))
|
||||||
const selectorTargets = injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument);
|
bestTokens = labelTokens;
|
||||||
if (!selectorTargets.length)
|
|
||||||
break;
|
const selector = joinTokens(bestTokens);
|
||||||
if (selectorTargets[0] === targetElement)
|
const parsedSelector = injectedScript.parseSelector(selector);
|
||||||
return { selector: fullSelector, elements: selectorTargets };
|
|
||||||
if (selectorTargets.length && numberOfMatchingElements > selectorTargets.length) {
|
|
||||||
numberOfMatchingElements = selectorTargets.length;
|
|
||||||
path.unshift(selector);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (document.documentElement === targetElement) {
|
|
||||||
return {
|
return {
|
||||||
selector: '/html',
|
selector,
|
||||||
elements: [document.documentElement]
|
elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument)
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
cacheAllowText.clear();
|
||||||
|
cacheDisallowText.clear();
|
||||||
|
injectedScript._evaluator.end();
|
||||||
}
|
}
|
||||||
const selector =
|
|
||||||
createXPath(document.documentElement, targetElement) ||
|
|
||||||
cssSelectorForElement(injectedScript, targetElement);
|
|
||||||
const parsedSelector = injectedScript.parseSelector(selector);
|
|
||||||
return {
|
|
||||||
selector,
|
|
||||||
elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSelectorCandidate(element: Element): SelectorToken | null {
|
function generateSelectorFor(injectedScript: InjectedScript, targetElement: Element): SelectorToken[] {
|
||||||
const nodeName = element.nodeName.toLowerCase();
|
if (targetElement.ownerDocument.documentElement === targetElement)
|
||||||
|
return [{ engine: 'css', selector: 'html', score: 1 }];
|
||||||
|
|
||||||
|
const calculate = (element: Element, allowText: boolean): SelectorToken[] | null => {
|
||||||
|
const allowNthMatch = element === targetElement;
|
||||||
|
|
||||||
|
const textCandidates = allowText ? buildTextCandidates(injectedScript, element, element === targetElement).map(token => [token]) : [];
|
||||||
|
const noTextCandidates = buildCandidates(injectedScript, element).map(token => [token]);
|
||||||
|
let result = chooseFirstSelector(injectedScript, targetElement.ownerDocument, element, [...textCandidates, ...noTextCandidates], allowNthMatch);
|
||||||
|
|
||||||
|
const checkWithText = (textCandidatesToUse: SelectorToken[][]) => {
|
||||||
|
const allowParentText = allowText && !textCandidatesToUse.length;
|
||||||
|
const candidates = [...textCandidatesToUse, ...noTextCandidates];
|
||||||
|
for (let parent = parentElementOrShadowHost(element); parent; parent = parentElementOrShadowHost(parent)) {
|
||||||
|
const best = chooseFirstSelector(injectedScript, parent, element, candidates, allowNthMatch);
|
||||||
|
if (!best)
|
||||||
|
continue;
|
||||||
|
if (result && combineScores(best) >= combineScores(result))
|
||||||
|
continue;
|
||||||
|
const parentTokens = find(parent, allowParentText);
|
||||||
|
if (!parentTokens)
|
||||||
|
continue;
|
||||||
|
if (!result || combineScores([...parentTokens, ...best]) < combineScores(result))
|
||||||
|
result = [...parentTokens, ...best];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkWithText(textCandidates);
|
||||||
|
// Allow skipping text on the target element.
|
||||||
|
if (element === targetElement && textCandidates.length)
|
||||||
|
checkWithText([]);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const find = (element: Element, allowText: boolean): SelectorToken[] | null => {
|
||||||
|
const cache = allowText ? cacheAllowText : cacheDisallowText;
|
||||||
|
let value = cache.get(element);
|
||||||
|
if (value === undefined) {
|
||||||
|
value = calculate(element, allowText);
|
||||||
|
cache.set(element, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const smartTokens = find(targetElement, true);
|
||||||
|
if (smartTokens)
|
||||||
|
return smartTokens;
|
||||||
|
|
||||||
|
return [cssFallback(injectedScript, targetElement)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCandidates(injectedScript: InjectedScript, element: Element): SelectorToken[] {
|
||||||
|
const candidates: SelectorToken[] = [];
|
||||||
for (const attribute of ['data-testid', 'data-test-id', 'data-test']) {
|
for (const attribute of ['data-testid', 'data-test-id', 'data-test']) {
|
||||||
if (element.hasAttribute(attribute))
|
if (element.hasAttribute(attribute))
|
||||||
return { engine: 'css', selector: `${nodeName}[${attribute}=${quoteString(element.getAttribute(attribute)!)}]` };
|
candidates.push({ engine: 'css', selector: `[${attribute}=${quoteString(element.getAttribute(attribute)!)}]`, score: 1 });
|
||||||
}
|
}
|
||||||
for (const attribute of ['aria-label', 'role']) {
|
|
||||||
if (element.hasAttribute(attribute))
|
|
||||||
return { engine: 'css', selector: `${element.nodeName.toLocaleLowerCase()}[${attribute}=${quoteString(element.getAttribute(attribute)!)}]` };
|
|
||||||
}
|
|
||||||
if (['INPUT', 'TEXTAREA'].includes(element.nodeName)) {
|
|
||||||
const nodeNameLowercase = element.nodeName.toLowerCase();
|
|
||||||
if (element.getAttribute('name'))
|
|
||||||
return { engine: 'css', selector: `${nodeNameLowercase}[name=${quoteString(element.getAttribute('name')!)}]` };
|
|
||||||
if (element.getAttribute('placeholder'))
|
|
||||||
return { engine: 'css', selector: `${nodeNameLowercase}[placeholder=${quoteString(element.getAttribute('placeholder')!)}]` };
|
|
||||||
if (element.getAttribute('type'))
|
|
||||||
return { engine: 'css', selector: `${nodeNameLowercase}[type=${quoteString(element.getAttribute('type')!)}]` };
|
|
||||||
} else if (element.nodeName === 'IMG') {
|
|
||||||
if (element.getAttribute('alt'))
|
|
||||||
return { engine: 'css', selector: `img[alt=${quoteString(element.getAttribute('alt')!)}]` };
|
|
||||||
}
|
|
||||||
const textSelector = textSelectorForElement(element);
|
|
||||||
if (textSelector)
|
|
||||||
return { engine: 'text', selector: textSelector };
|
|
||||||
|
|
||||||
// De-prioritize id, but still use it as a last resort.
|
if (element.nodeName === 'INPUT') {
|
||||||
|
const input = element as HTMLInputElement;
|
||||||
|
if (input.placeholder)
|
||||||
|
candidates.push({ engine: 'css', selector: `[placeholder=${quoteString(input.placeholder)}]`, score: 10 });
|
||||||
|
}
|
||||||
|
if (element.hasAttribute('aria-label'))
|
||||||
|
candidates.push({ engine: 'css', selector: `[aria-label=${quoteString(element.getAttribute('aria-label')!)}]`, score: 10 });
|
||||||
|
if (element.nodeName === 'IMG' && element.getAttribute('alt'))
|
||||||
|
candidates.push({ engine: 'css', selector: `img[alt=${quoteString(element.getAttribute('alt')!)}]`, score: 10 });
|
||||||
|
|
||||||
|
if (element.hasAttribute('role'))
|
||||||
|
candidates.push({ engine: 'css', selector: `${element.nodeName.toLocaleLowerCase()}[role=${quoteString(element.getAttribute('role')!)}]` , score: 50 });
|
||||||
|
if (['INPUT', 'TEXTAREA'].includes(element.nodeName) && element.getAttribute('type') !== 'hidden') {
|
||||||
|
if (element.getAttribute('name'))
|
||||||
|
candidates.push({ engine: 'css', selector: `${element.nodeName.toLowerCase()}[name=${quoteString(element.getAttribute('name')!)}]`, score: 50 });
|
||||||
|
if (element.getAttribute('type'))
|
||||||
|
candidates.push({ engine: 'css', selector: `${element.nodeName.toLowerCase()}[type=${quoteString(element.getAttribute('type')!)}]`, score: 50 });
|
||||||
|
}
|
||||||
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName))
|
||||||
|
candidates.push({ engine: 'css', selector: element.nodeName.toLowerCase(), score: 50 });
|
||||||
|
|
||||||
const idAttr = element.getAttribute('id');
|
const idAttr = element.getAttribute('id');
|
||||||
if (idAttr && !isGuidLike(idAttr))
|
if (idAttr && !isGuidLike(idAttr))
|
||||||
return { engine: 'css', selector: `${nodeName}[id=${quoteString(idAttr!)}]` };
|
candidates.push({ engine: 'css', selector: `#${idAttr}`, score: 100 });
|
||||||
|
|
||||||
return null;
|
candidates.push({ engine: 'css', selector: element.nodeName.toLocaleLowerCase(), score: 200 });
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextCandidates(injectedScript: InjectedScript, element: Element, allowHasText: boolean): SelectorToken[] {
|
||||||
|
if (element.nodeName === 'SELECT')
|
||||||
|
return [];
|
||||||
|
const text = elementText(injectedScript._evaluator, element).trim().replace(/\s+/g, ' ').substring(0, 80);
|
||||||
|
if (!text)
|
||||||
|
return [];
|
||||||
|
const candidates: SelectorToken[] = [];
|
||||||
|
|
||||||
|
let escaped = text;
|
||||||
|
if (text.includes('"') || text.includes('>>') || text[0] === '/')
|
||||||
|
escaped = `/.*${escapeForRegex(text)}.*/`;
|
||||||
|
|
||||||
|
candidates.push({ engine: 'text', selector: escaped, score: 10 });
|
||||||
|
if (allowHasText && escaped === text) {
|
||||||
|
let prefix = element.nodeName.toLocaleLowerCase();
|
||||||
|
if (element.hasAttribute('role'))
|
||||||
|
prefix += `[role=${quoteString(element.getAttribute('role')!)}]`;
|
||||||
|
candidates.push({ engine: 'css', selector: `${prefix}:has-text("${text}")`, score: 30 });
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentElementOrShadowHost(element: Element): Element | null {
|
function parentElementOrShadowHost(element: Element): Element | null {
|
||||||
|
|
@ -95,7 +171,27 @@ function parentElementOrShadowHost(element: Element): Element | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cssSelectorForElement(injectedScript: InjectedScript, targetElement: Element): string {
|
function ancestorShadowRoot(element: Element): ShadowRoot | null {
|
||||||
|
while (element.parentElement)
|
||||||
|
element = element.parentElement;
|
||||||
|
if (element.parentNode && element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE)
|
||||||
|
return element.parentNode as ShadowRoot;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTargetLabel(element: Element): Element | null {
|
||||||
|
const docOrShadowRoot = ancestorShadowRoot(element) || element.ownerDocument!;
|
||||||
|
const labels = docOrShadowRoot.querySelectorAll('label');
|
||||||
|
for (const element of labels) {
|
||||||
|
const label = element as HTMLLabelElement;
|
||||||
|
if (label.control === element)
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssFallback(injectedScript: InjectedScript, targetElement: Element): SelectorToken {
|
||||||
|
const kFallbackScore = 10000000;
|
||||||
const root: Node = targetElement.ownerDocument;
|
const root: Node = targetElement.ownerDocument;
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
|
|
||||||
|
|
@ -118,7 +214,7 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El
|
||||||
const token = /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(element.id) ? '#' + element.id : `[id="${element.id}"]`;
|
const token = /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(element.id) ? '#' + element.id : `[id="${element.id}"]`;
|
||||||
const selector = uniqueCSSSelector(token);
|
const selector = uniqueCSSSelector(token);
|
||||||
if (selector)
|
if (selector)
|
||||||
return selector;
|
return { engine: 'css', selector, score: kFallbackScore };
|
||||||
bestTokenForLevel = token;
|
bestTokenForLevel = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,7 +226,7 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El
|
||||||
const token = '.' + classes.slice(0, i + 1).join('.');
|
const token = '.' + classes.slice(0, i + 1).join('.');
|
||||||
const selector = uniqueCSSSelector(token);
|
const selector = uniqueCSSSelector(token);
|
||||||
if (selector)
|
if (selector)
|
||||||
return selector;
|
return { engine: 'css', selector, score: kFallbackScore };
|
||||||
// Even if not unique, does this subset of classes uniquely identify node as a child?
|
// Even if not unique, does this subset of classes uniquely identify node as a child?
|
||||||
if (!bestTokenForLevel && parent) {
|
if (!bestTokenForLevel && parent) {
|
||||||
const sameClassSiblings = parent.querySelectorAll(token);
|
const sameClassSiblings = parent.querySelectorAll(token);
|
||||||
|
|
@ -146,7 +242,7 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El
|
||||||
const token = sameTagSiblings.indexOf(element) === 0 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`;
|
const token = sameTagSiblings.indexOf(element) === 0 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`;
|
||||||
const selector = uniqueCSSSelector(token);
|
const selector = uniqueCSSSelector(token);
|
||||||
if (selector)
|
if (selector)
|
||||||
return selector;
|
return { engine: 'css', selector, score: kFallbackScore };
|
||||||
if (!bestTokenForLevel)
|
if (!bestTokenForLevel)
|
||||||
bestTokenForLevel = token;
|
bestTokenForLevel = token;
|
||||||
} else if (!bestTokenForLevel) {
|
} else if (!bestTokenForLevel) {
|
||||||
|
|
@ -154,56 +250,62 @@ function cssSelectorForElement(injectedScript: InjectedScript, targetElement: El
|
||||||
}
|
}
|
||||||
tokens.unshift(bestTokenForLevel);
|
tokens.unshift(bestTokenForLevel);
|
||||||
}
|
}
|
||||||
return uniqueCSSSelector()!;
|
return { engine: 'css', selector: uniqueCSSSelector()!, score: kFallbackScore };
|
||||||
}
|
|
||||||
|
|
||||||
function textSelectorForElement(node: Node): string | null {
|
|
||||||
const maxLength = 30;
|
|
||||||
let needsRegex = false;
|
|
||||||
let trimmedText: string | null = null;
|
|
||||||
for (const child of node.childNodes) {
|
|
||||||
if (child.nodeType !== Node.TEXT_NODE)
|
|
||||||
continue;
|
|
||||||
if (child.textContent && child.textContent.trim()) {
|
|
||||||
if (trimmedText)
|
|
||||||
return null;
|
|
||||||
trimmedText = child.textContent.trim().substr(0, maxLength);
|
|
||||||
needsRegex = child.textContent !== trimmedText;
|
|
||||||
} else {
|
|
||||||
needsRegex = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!trimmedText)
|
|
||||||
return null;
|
|
||||||
return needsRegex ? `/.*${escapeForRegex(trimmedText)}.*/` : `"${trimmedText}"`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeForRegex(text: string): string {
|
function escapeForRegex(text: string): string {
|
||||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return text.replace(/[.*+?^>${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteString(text: string): string {
|
function quoteString(text: string): string {
|
||||||
return `"${text.replaceAll(/"/g, '\\"')}"`;
|
return `"${text.replaceAll(/"/g, '\\"').replaceAll(/\n/g, '\\n')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectorToken = {
|
function joinTokens(tokens: SelectorToken[]): string {
|
||||||
engine: string;
|
const parts = [];
|
||||||
selector: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function joinSelector(path: SelectorToken[]): string {
|
|
||||||
const tokens = [];
|
|
||||||
let lastEngine = '';
|
let lastEngine = '';
|
||||||
for (const { engine, selector } of path) {
|
for (const { engine, selector } of tokens) {
|
||||||
if (tokens.length && (lastEngine !== 'css' || engine !== 'css'))
|
if (parts.length && (lastEngine !== 'css' || engine !== 'css'))
|
||||||
tokens.push('>>');
|
parts.push('>>');
|
||||||
lastEngine = engine;
|
lastEngine = engine;
|
||||||
if (engine === 'css')
|
if (engine === 'css')
|
||||||
tokens.push(selector);
|
parts.push(selector);
|
||||||
else
|
else
|
||||||
tokens.push(`${engine}=${selector}`);
|
parts.push(`${engine}=${selector}`);
|
||||||
}
|
}
|
||||||
return tokens.join(' ');
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineScores(tokens: SelectorToken[]): number {
|
||||||
|
let score = 0;
|
||||||
|
for (let i = 0; i < tokens.length; i++)
|
||||||
|
score += tokens[i].score * (tokens.length - i);
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseFirstSelector(injectedScript: InjectedScript, scope: Element | Document, targetElement: Element, selectors: SelectorToken[][], allowNthMatch: boolean): SelectorToken[] | null {
|
||||||
|
const joined = selectors.map(tokens => ({ tokens, score: combineScores(tokens) }));
|
||||||
|
joined.sort((a, b) => a.score - b.score);
|
||||||
|
let bestWithIndex: SelectorToken[] | null = null;
|
||||||
|
for (const { tokens } of joined) {
|
||||||
|
const parsedSelector = injectedScript.parseSelector(joinTokens(tokens));
|
||||||
|
const result = injectedScript.querySelectorAll(parsedSelector, scope);
|
||||||
|
const index = result.indexOf(targetElement);
|
||||||
|
if (index === 0)
|
||||||
|
return tokens;
|
||||||
|
if (!allowNthMatch || bestWithIndex || index === -1 || result.length > 5)
|
||||||
|
continue;
|
||||||
|
const allCss = tokens.map(token => {
|
||||||
|
if (token.engine !== 'text')
|
||||||
|
return token;
|
||||||
|
if (token.selector.startsWith('/') && token.selector.endsWith('/'))
|
||||||
|
return { engine: 'css', selector: `:text-matches("${token.selector.substring(1, token.selector.length - 1)}")`, score: token.score };
|
||||||
|
return { engine: 'css', selector: `:text("${token.selector}")`, score: token.score };
|
||||||
|
});
|
||||||
|
const combined = joinTokens(allCss);
|
||||||
|
bestWithIndex = [{ engine: 'css', selector: `:nth-match(${combined}, ${index + 1})`, score: combineScores(allCss) + 1000 }];
|
||||||
|
}
|
||||||
|
return bestWithIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGuidLike(id: string): boolean {
|
function isGuidLike(id: string): boolean {
|
||||||
|
|
@ -216,7 +318,7 @@ function isGuidLike(id: string): boolean {
|
||||||
continue;
|
continue;
|
||||||
if (c >= 'a' && c <= 'z')
|
if (c >= 'a' && c <= 'z')
|
||||||
characterType = 'lower';
|
characterType = 'lower';
|
||||||
else if (c >= 'A' && c <= 'Z')
|
else if (c >= 'A' && c <= 'Z')
|
||||||
characterType = 'upper';
|
characterType = 'upper';
|
||||||
else if (c >= '0' && c <= '9')
|
else if (c >= '0' && c <= '9')
|
||||||
characterType = 'digit';
|
characterType = 'digit';
|
||||||
|
|
@ -234,130 +336,3 @@ function isGuidLike(id: string): boolean {
|
||||||
}
|
}
|
||||||
return transitionCount >= id.length / 4;
|
return transitionCount >= id.length / 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createXPath(root: Node, targetElement: Element): string | undefined {
|
|
||||||
const maxTextLength = 80;
|
|
||||||
const minMeaningfulSelectorLegth = 100;
|
|
||||||
|
|
||||||
const maybeDocument = root instanceof Document ? root : root.ownerDocument;
|
|
||||||
if (!maybeDocument)
|
|
||||||
return;
|
|
||||||
const document = maybeDocument;
|
|
||||||
|
|
||||||
const xpathCache = new Map<string, Element[]>();
|
|
||||||
const tokens: string[] = [];
|
|
||||||
|
|
||||||
function evaluateXPath(expression: string): Element[] {
|
|
||||||
let nodes: Element[] | undefined = xpathCache.get(expression);
|
|
||||||
if (!nodes) {
|
|
||||||
nodes = [];
|
|
||||||
try {
|
|
||||||
const result = document.evaluate(expression, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
|
|
||||||
for (let node = result.iterateNext(); node; node = result.iterateNext()) {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE)
|
|
||||||
nodes.push(node as Element);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
xpathCache.set(expression, nodes);
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueXPathSelector(prefix?: string): string | undefined {
|
|
||||||
const path = tokens.slice();
|
|
||||||
if (prefix)
|
|
||||||
path.unshift(prefix);
|
|
||||||
let selector = '//' + path.join('/');
|
|
||||||
while (selector.includes('///'))
|
|
||||||
selector = selector.replace('///', '//');
|
|
||||||
if (selector.endsWith('/'))
|
|
||||||
selector = selector.substring(0, selector.length - 1);
|
|
||||||
const nodes: Element[] = evaluateXPath(selector);
|
|
||||||
if (nodes[0] === targetElement)
|
|
||||||
return selector;
|
|
||||||
|
|
||||||
// If we are looking at a small set of elements with long selector, fall back to ordinal.
|
|
||||||
if (nodes.length < 5 && selector.length > minMeaningfulSelectorLegth) {
|
|
||||||
const index = nodes.indexOf(targetElement);
|
|
||||||
if (index !== -1)
|
|
||||||
return `(${selector})[${index + 1}]`;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAndCap(text: string) {
|
|
||||||
text = text.substring(0, maxTextLength);
|
|
||||||
// XPath 1.0 does not support quote escaping.
|
|
||||||
// 1. If there are no single quotes - use them.
|
|
||||||
if (text.indexOf(`'`) === -1)
|
|
||||||
return `'${text}'`;
|
|
||||||
// 2. If there are no double quotes - use them to enclose text.
|
|
||||||
if (text.indexOf(`"`) === -1)
|
|
||||||
return `"${text}"`;
|
|
||||||
// 3. Otherwise, use popular |concat| trick.
|
|
||||||
const Q = `'`;
|
|
||||||
return `concat(${text.split(Q).map(token => Q + token + Q).join(`, "'", `)})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultAttributes = new Set([ 'title', 'aria-label', 'disabled', 'role' ]);
|
|
||||||
const importantAttributes = new Map<string, string[]>([
|
|
||||||
[ 'form', [ 'action' ] ],
|
|
||||||
[ 'img', [ 'alt' ] ],
|
|
||||||
[ 'input', [ 'placeholder', 'type', 'name' ] ],
|
|
||||||
[ 'textarea', [ 'placeholder', 'type', 'name' ] ],
|
|
||||||
]);
|
|
||||||
|
|
||||||
let usedTextConditions = false;
|
|
||||||
for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) {
|
|
||||||
const nodeName = element.nodeName.toLowerCase();
|
|
||||||
const tag = nodeName === 'svg' ? '*' : nodeName;
|
|
||||||
|
|
||||||
const tagConditions = [];
|
|
||||||
if (nodeName === 'svg')
|
|
||||||
tagConditions.push('local-name()="svg"');
|
|
||||||
|
|
||||||
const attrConditions: string[] = [];
|
|
||||||
const importantAttrs = [ ...defaultAttributes, ...(importantAttributes.get(tag) || []) ];
|
|
||||||
for (const attr of importantAttrs) {
|
|
||||||
const value = element.getAttribute(attr);
|
|
||||||
if (value && value.length < maxTextLength)
|
|
||||||
attrConditions.push(`normalize-space(@${attr})=${escapeAndCap(value)}`);
|
|
||||||
else if (value)
|
|
||||||
attrConditions.push(`starts-with(normalize-space(@${attr}), ${escapeAndCap(value)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = document.evaluate('normalize-space(.)', element).stringValue;
|
|
||||||
const textConditions = [];
|
|
||||||
if (tag !== 'select' && text.length && !usedTextConditions) {
|
|
||||||
if (text.length < maxTextLength)
|
|
||||||
textConditions.push(`normalize-space(.)=${escapeAndCap(text)}`);
|
|
||||||
else
|
|
||||||
textConditions.push(`starts-with(normalize-space(.), ${escapeAndCap(text)})`);
|
|
||||||
usedTextConditions = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always retain the last tag.
|
|
||||||
const conditions = [ ...tagConditions, ...textConditions, ...attrConditions ];
|
|
||||||
const token = conditions.length ? `${tag}[${conditions.join(' and ')}]` : (tokens.length ? '' : tag);
|
|
||||||
const selector = uniqueXPathSelector(token);
|
|
||||||
if (selector)
|
|
||||||
return selector;
|
|
||||||
|
|
||||||
const parent = element.parentElement;
|
|
||||||
let ordinal = -1;
|
|
||||||
if (parent) {
|
|
||||||
const siblings = Array.from(parent.children);
|
|
||||||
const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName);
|
|
||||||
if (sameTagSiblings.length > 1)
|
|
||||||
ordinal = sameTagSiblings.indexOf(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not include text into this token, only tag / attributes.
|
|
||||||
// Topmost node will get all the text.
|
|
||||||
const conditionsString = conditions.length ? `[${conditions.join(' and ')}]` : '';
|
|
||||||
const ordinalString = ordinal >= 0 ? `[${ordinal + 1}]` : '';
|
|
||||||
tokens.unshift(`${tag}${ordinalString}${conditionsString}`);
|
|
||||||
}
|
|
||||||
return uniqueXPathSelector();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await recorder.setContentAndWait(`<button onclick="console.log('click')">Submit</button>`);
|
await recorder.setContentAndWait(`<button onclick="console.log('click')">Submit</button>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('button');
|
const selector = await recorder.hoverOverElement('button');
|
||||||
expect(selector).toBe('text="Submit"');
|
expect(selector).toBe('text=Submit');
|
||||||
|
|
||||||
const [message] = await Promise.all([
|
const [message] = await Promise.all([
|
||||||
page.waitForEvent('console'),
|
page.waitForEvent('console'),
|
||||||
|
|
@ -35,8 +35,8 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page.dispatchEvent('button', 'click', { detail: 1 })
|
page.dispatchEvent('button', 'click', { detail: 1 })
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="Submit"
|
// Click text=Submit
|
||||||
await page.click('text="Submit"');`);
|
await page.click('text=Submit');`);
|
||||||
expect(message.text()).toBe('click');
|
expect(message.text()).toBe('click');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('button');
|
const selector = await recorder.hoverOverElement('button');
|
||||||
expect(selector).toBe('text="Submit"');
|
expect(selector).toBe('text=Submit');
|
||||||
|
|
||||||
const [message] = await Promise.all([
|
const [message] = await Promise.all([
|
||||||
page.waitForEvent('console'),
|
page.waitForEvent('console'),
|
||||||
|
|
@ -63,8 +63,8 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page.dispatchEvent('button', 'click', { detail: 1 })
|
page.dispatchEvent('button', 'click', { detail: 1 })
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="Submit"
|
// Click text=Submit
|
||||||
await page.click('text="Submit"');`);
|
await page.click('text=Submit');`);
|
||||||
expect(message.text()).toBe('click');
|
expect(message.text()).toBe('click');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -83,7 +83,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('div');
|
const selector = await recorder.hoverOverElement('div');
|
||||||
expect(selector).toBe('text=/.*Some long text here.*/');
|
expect(selector).toBe('text=Some long text here');
|
||||||
|
|
||||||
// Sanity check that selector does not match our highlight.
|
// Sanity check that selector does not match our highlight.
|
||||||
const divContents = await page.$eval(selector, div => div.outerHTML);
|
const divContents = await page.$eval(selector, div => div.outerHTML);
|
||||||
|
|
@ -95,8 +95,8 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page.dispatchEvent('div', 'click', { detail: 1 })
|
page.dispatchEvent('div', 'click', { detail: 1 })
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text=/.*Some long text here.*/
|
// Click text=Some long text here
|
||||||
await page.click('text=/.*Some long text here.*/');`);
|
await page.click('text=Some long text here');`);
|
||||||
expect(message.text()).toBe('click');
|
expect(message.text()).toBe('click');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -279,7 +279,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await recorder.setContentAndWait('<select id="age" onchange="console.log(age.selectedOptions[0].value)"><option value="1"><option value="2"></select>');
|
await recorder.setContentAndWait('<select id="age" onchange="console.log(age.selectedOptions[0].value)"><option value="1"><option value="2"></select>');
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('select');
|
const selector = await recorder.hoverOverElement('select');
|
||||||
expect(selector).toBe('select[id="age"]');
|
expect(selector).toBe('select');
|
||||||
|
|
||||||
const [message] = await Promise.all([
|
const [message] = await Promise.all([
|
||||||
page.waitForEvent('console'),
|
page.waitForEvent('console'),
|
||||||
|
|
@ -288,7 +288,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Select 2
|
// Select 2
|
||||||
await page.selectOption('select[id="age"]', '2');`);
|
await page.selectOption('select', '2');`);
|
||||||
expect(message.text()).toBe('2');
|
expect(message.text()).toBe('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -298,7 +298,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await recorder.setContentAndWait('<a target=_blank rel=noopener href="about:blank">link</a>');
|
await recorder.setContentAndWait('<a target=_blank rel=noopener href="about:blank">link</a>');
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('a');
|
const selector = await recorder.hoverOverElement('a');
|
||||||
expect(selector).toBe('text="link"');
|
expect(selector).toBe('text=link');
|
||||||
|
|
||||||
const [popup] = await Promise.all([
|
const [popup] = await Promise.all([
|
||||||
page.context().waitForEvent('page'),
|
page.context().waitForEvent('page'),
|
||||||
|
|
@ -306,10 +306,10 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page.dispatchEvent('a', 'click', { detail: 1 })
|
page.dispatchEvent('a', 'click', { detail: 1 })
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="link"
|
// Click text=link
|
||||||
const [page1] = await Promise.all([
|
const [page1] = await Promise.all([
|
||||||
page.waitForEvent('popup'),
|
page.waitForEvent('popup'),
|
||||||
page.click('text="link"')
|
page.click('text=link')
|
||||||
]);`);
|
]);`);
|
||||||
expect(popup.url()).toBe('about:blank');
|
expect(popup.url()).toBe('about:blank');
|
||||||
});
|
});
|
||||||
|
|
@ -318,15 +318,15 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
|
await recorder.setContentAndWait(`<a onclick="window.location.href='about:blank#foo'">link</a>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('a');
|
const selector = await recorder.hoverOverElement('a');
|
||||||
expect(selector).toBe('text="link"');
|
expect(selector).toBe('text=link');
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForNavigation(),
|
page.waitForNavigation(),
|
||||||
recorder.waitForOutput('assert'),
|
recorder.waitForOutput('assert'),
|
||||||
page.dispatchEvent('a', 'click', { detail: 1 })
|
page.dispatchEvent('a', 'click', { detail: 1 })
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="link"
|
// Click text=link
|
||||||
await page.click('text="link"');
|
await page.click('text=link');
|
||||||
// assert.equal(page.url(), 'about:blank#foo');`);
|
// assert.equal(page.url(), 'about:blank#foo');`);
|
||||||
expect(page.url()).toContain('about:blank#foo');
|
expect(page.url()).toContain('about:blank#foo');
|
||||||
});
|
});
|
||||||
|
|
@ -336,7 +336,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await recorder.setContentAndWait(`<a onclick="setTimeout(() => window.location.href='about:blank#foo', 1000)">link</a>`);
|
await recorder.setContentAndWait(`<a onclick="setTimeout(() => window.location.href='about:blank#foo', 1000)">link</a>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('a');
|
const selector = await recorder.hoverOverElement('a');
|
||||||
expect(selector).toBe('text="link"');
|
expect(selector).toBe('text=link');
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForNavigation(),
|
page.waitForNavigation(),
|
||||||
|
|
@ -344,10 +344,10 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page.dispatchEvent('a', 'click', { detail: 1 })
|
page.dispatchEvent('a', 'click', { detail: 1 })
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="link"
|
// Click text=link
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForNavigation(/*{ url: 'about:blank#foo' }*/),
|
page.waitForNavigation(/*{ url: 'about:blank#foo' }*/),
|
||||||
page.click('text="link"')
|
page.click('text=link')
|
||||||
]);`);
|
]);`);
|
||||||
expect(page.url()).toContain('about:blank#foo');
|
expect(page.url()).toContain('about:blank#foo');
|
||||||
});
|
});
|
||||||
|
|
@ -371,14 +371,14 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await recorder.waitForOutput('page.close();');
|
await recorder.waitForOutput('page.close();');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not lead to an error if /html gets clicked', async ({ contextWrapper, recorder }) => {
|
it('should not lead to an error if html gets clicked', async ({ contextWrapper, recorder }) => {
|
||||||
await recorder.setContentAndWait('');
|
await recorder.setContentAndWait('');
|
||||||
await contextWrapper.context.newPage();
|
await contextWrapper.context.newPage();
|
||||||
const errors: any[] = [];
|
const errors: any[] = [];
|
||||||
recorder.page.on('pageerror', e => errors.push(e));
|
recorder.page.on('pageerror', e => errors.push(e));
|
||||||
await recorder.page.evaluate(() => document.querySelector('body').remove());
|
await recorder.page.evaluate(() => document.querySelector('body').remove());
|
||||||
const selector = await recorder.hoverOverElement('html');
|
const selector = await recorder.hoverOverElement('html');
|
||||||
expect(selector).toBe('/html');
|
expect(selector).toBe('html');
|
||||||
await recorder.page.close();
|
await recorder.page.close();
|
||||||
await recorder.waitForOutput('page.close();');
|
await recorder.waitForOutput('page.close();');
|
||||||
expect(errors.length).toBe(0);
|
expect(errors.length).toBe(0);
|
||||||
|
|
@ -455,10 +455,10 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page.click('text=Download')
|
page.click('text=Download')
|
||||||
]);
|
]);
|
||||||
await recorder.waitForOutput(`
|
await recorder.waitForOutput(`
|
||||||
// Click text="Download"
|
// Click text=Download
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.click('text="Download"')
|
page.click('text=Download')
|
||||||
]);`);
|
]);`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -470,14 +470,14 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page.once('dialog', async dialog => {
|
page.once('dialog', async dialog => {
|
||||||
await dialog.dismiss();
|
await dialog.dismiss();
|
||||||
});
|
});
|
||||||
await page.click('text="click me"');
|
await page.click('text=click me');
|
||||||
await recorder.waitForOutput(`
|
await recorder.waitForOutput(`
|
||||||
// Click text="click me"
|
// Click text=click me
|
||||||
page.once('dialog', dialog => {
|
page.once('dialog', dialog => {
|
||||||
console.log(\`Dialog message: $\{dialog.message()}\`);
|
console.log(\`Dialog message: $\{dialog.message()}\`);
|
||||||
dialog.dismiss().catch(() => {});
|
dialog.dismiss().catch(() => {});
|
||||||
});
|
});
|
||||||
await page.click('text="click me"')`);
|
await page.click('text=click me')`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle history.postData', async ({ page, recorder, httpServer }) => {
|
it('should handle history.postData', async ({ page, recorder, httpServer }) => {
|
||||||
|
|
@ -504,7 +504,7 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await recorder.setContentAndWait(`<a href="about:blank?foo">link</a>`);
|
await recorder.setContentAndWait(`<a href="about:blank?foo">link</a>`);
|
||||||
|
|
||||||
const selector = await recorder.hoverOverElement('a');
|
const selector = await recorder.hoverOverElement('a');
|
||||||
expect(selector).toBe('text="link"');
|
expect(selector).toBe('text=link');
|
||||||
|
|
||||||
await page.click('a', { modifiers: [ platform === 'darwin' ? 'Meta' : 'Control'] });
|
await page.click('a', { modifiers: [ platform === 'darwin' ? 'Meta' : 'Control'] });
|
||||||
await recorder.waitForOutput('page1');
|
await recorder.waitForOutput('page1');
|
||||||
|
|
@ -515,10 +515,10 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
page1.goto('about:blank?foo');`);
|
page1.goto('about:blank?foo');`);
|
||||||
} else if (browserName === 'firefox') {
|
} else if (browserName === 'firefox') {
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="link"
|
// Click text=link
|
||||||
const [page1] = await Promise.all([
|
const [page1] = await Promise.all([
|
||||||
page.waitForEvent('popup'),
|
page.waitForEvent('popup'),
|
||||||
page.click('text="link"', {
|
page.click('text=link', {
|
||||||
modifiers: ['${platform === 'darwin' ? 'Meta' : 'Control'}']
|
modifiers: ['${platform === 'darwin' ? 'Meta' : 'Control'}']
|
||||||
})
|
})
|
||||||
]);`);
|
]);`);
|
||||||
|
|
@ -546,8 +546,8 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
await popup2.type('input', 'TextB');
|
await popup2.type('input', 'TextB');
|
||||||
await recorder.waitForOutput('TextB');
|
await recorder.waitForOutput('TextB');
|
||||||
|
|
||||||
expect(recorder.output()).toContain(`await page1.fill('input[id="name"]', 'TextA');`);
|
expect(recorder.output()).toContain(`await page1.fill('input', 'TextA');`);
|
||||||
expect(recorder.output()).toContain(`await page2.fill('input[id="name"]', 'TextB');`);
|
expect(recorder.output()).toContain(`await page2.fill('input', 'TextB');`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('click should emit events in order', async ({ page, recorder }) => {
|
it('click should emit events in order', async ({ page, recorder }) => {
|
||||||
|
|
@ -613,30 +613,30 @@ describe('cli codegen', (test, { browserName, headful }) => {
|
||||||
frameOne.click('div'),
|
frameOne.click('div'),
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="Hi, I'm frame"
|
// Click text=Hi, I'm frame
|
||||||
await page.frame({
|
await page.frame({
|
||||||
name: 'one'
|
name: 'one'
|
||||||
}).click('text="Hi, I\\'m frame"');`);
|
}).click('text=Hi, I\\'m frame');`);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
recorder.waitForOutput('two'),
|
recorder.waitForOutput('two'),
|
||||||
frameTwo.click('div'),
|
frameTwo.click('div'),
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="Hi, I'm frame"
|
// Click text=Hi, I'm frame
|
||||||
await page.frame({
|
await page.frame({
|
||||||
name: 'two'
|
name: 'two'
|
||||||
}).click('text="Hi, I\\'m frame"');`);
|
}).click('text=Hi, I\\'m frame');`);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
recorder.waitForOutput('url: \''),
|
recorder.waitForOutput('url: \''),
|
||||||
otherFrame.click('div'),
|
otherFrame.click('div'),
|
||||||
]);
|
]);
|
||||||
expect(recorder.output()).toContain(`
|
expect(recorder.output()).toContain(`
|
||||||
// Click text="Hi, I'm frame"
|
// Click text=Hi, I'm frame
|
||||||
await page.frame({
|
await page.frame({
|
||||||
url: '${otherFrame.url()}'
|
url: '${otherFrame.url()}'
|
||||||
}).click('text="Hi, I\\'m frame"');`);
|
}).click('text=Hi, I\\'m frame');`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should record navigations after identical pushState', async ({ page, recorder, httpServer }) => {
|
it('should record navigations after identical pushState', async ({ page, recorder, httpServer }) => {
|
||||||
|
|
|
||||||
|
|
@ -31,19 +31,62 @@ async function generate(pageOrFrame: Page | Frame, target: string): Promise<stri
|
||||||
describe('selector generator', (suite, { mode }) => {
|
describe('selector generator', (suite, { mode }) => {
|
||||||
suite.skip(mode !== 'default');
|
suite.skip(mode !== 'default');
|
||||||
}, () => {
|
}, () => {
|
||||||
it('should generate for text', async ({ page }) => {
|
it('should prefer button over inner span', async ({ page }) => {
|
||||||
await page.setContent(`<div>Text</div>`);
|
await page.setContent(`<button id=clickme><span></span></button>`);
|
||||||
expect(await generate(page, 'div')).toBe('text="Text"');
|
expect(await generate(page, 'button')).toBe('#clickme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer role=button over inner span', async ({ page }) => {
|
||||||
|
await page.setContent(`<div role=button><span></span></div>`);
|
||||||
|
expect(await generate(page, 'div')).toBe('div[role="button"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate text and normalize whitespace', async ({ page }) => {
|
||||||
|
await page.setContent(`<div>Text some\n\n\n more \t text </div>`);
|
||||||
|
expect(await generate(page, 'div')).toBe('text=Text some more text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate text for <input type=button>', async ({ page }) => {
|
||||||
|
await page.setContent(`<input type=button value="Click me">`);
|
||||||
|
expect(await generate(page, 'input')).toBe('text=Click me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim text', async ({ page }) => {
|
||||||
|
await page.setContent(`<div>Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789</div>`);
|
||||||
|
expect(await generate(page, 'div')).toBe('text=Text0123456789Text0123456789Text0123456789Text0123456789Text0123456789Text012345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape text with >>', async ({ page }) => {
|
||||||
|
await page.setContent(`<div>text>>text</div>`);
|
||||||
|
expect(await generate(page, 'div')).toBe('text=/.*text\\>\\>text.*/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape text with quote', async ({ page }) => {
|
||||||
|
await page.setContent(`<div>text"text</div>`);
|
||||||
|
expect(await generate(page, 'div')).toBe('text=/.*text"text.*/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should escape text with slash', async ({ page }) => {
|
||||||
|
await page.setContent(`<div>/text</div>`);
|
||||||
|
expect(await generate(page, 'div')).toBe('text=/.*\/text.*/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use text for select', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<select><option>foo</option></select>
|
||||||
|
<select mark=1><option>bar</option></select>
|
||||||
|
`);
|
||||||
|
expect(await generate(page, '[mark="1"]')).toBe(':nth-match(select, 2)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use ordinal for identical nodes', async ({ page }) => {
|
it('should use ordinal for identical nodes', async ({ page }) => {
|
||||||
await page.setContent(`<div>Text</div><div>Text</div><div mark=1>Text</div><div>Text</div>`);
|
await page.setContent(`<div>Text</div><div>Text</div><div mark=1>Text</div><div>Text</div>`);
|
||||||
expect(await generate(page, 'div[mark="1"]')).toBe('//div[3][normalize-space(.)=\'Text\']');
|
expect(await generate(page, 'div[mark="1"]')).toBe(`:nth-match(:text("Text"), 3)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prefer data-testid', async ({ page }) => {
|
it('should prefer data-testid', async ({ page }) => {
|
||||||
await page.setContent(`<div>Text</div><div>Text</div><div data-testid=a>Text</div><div>Text</div>`);
|
await page.setContent(`<div>Text</div><div>Text</div><div data-testid=a>Text</div><div>Text</div>`);
|
||||||
expect(await generate(page, 'div[data-testid="a"]')).toBe('div[data-testid="a"]');
|
expect(await generate(page, '[data-testid="a"]')).toBe('[data-testid="a"]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle first non-unique data-testid', async ({ page }) => {
|
it('should handle first non-unique data-testid', async ({ page }) => {
|
||||||
|
|
@ -54,7 +97,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
<div data-testid=a>
|
<div data-testid=a>
|
||||||
Text
|
Text
|
||||||
</div>`);
|
</div>`);
|
||||||
expect(await generate(page, 'div[mark="1"]')).toBe('div[data-testid="a"]');
|
expect(await generate(page, 'div[mark="1"]')).toBe('[data-testid="a"]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle second non-unique data-testid', async ({ page }) => {
|
it('should handle second non-unique data-testid', async ({ page }) => {
|
||||||
|
|
@ -65,7 +108,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
<div data-testid=a mark=1>
|
<div data-testid=a mark=1>
|
||||||
Text
|
Text
|
||||||
</div>`);
|
</div>`);
|
||||||
expect(await generate(page, 'div[mark="1"]')).toBe('//div[2][normalize-space(.)=\'Text\']');
|
expect(await generate(page, 'div[mark="1"]')).toBe(`:nth-match([data-testid="a"], 2)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use readable id', async ({ page }) => {
|
it('should use readable id', async ({ page }) => {
|
||||||
|
|
@ -73,7 +116,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
<div></div>
|
<div></div>
|
||||||
<div id=first-item mark=1></div>
|
<div id=first-item mark=1></div>
|
||||||
`);
|
`);
|
||||||
expect(await generate(page, 'div[mark="1"]')).toBe('div[id="first-item"]');
|
expect(await generate(page, 'div[mark="1"]')).toBe('#first-item');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not use generated id', async ({ page }) => {
|
it('should not use generated id', async ({ page }) => {
|
||||||
|
|
@ -81,7 +124,31 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
<div></div>
|
<div></div>
|
||||||
<div id=aAbBcCdDeE mark=1></div>
|
<div id=aAbBcCdDeE mark=1></div>
|
||||||
`);
|
`);
|
||||||
expect(await generate(page, 'div[mark="1"]')).toBe('//div[2]');
|
expect(await generate(page, 'div[mark="1"]')).toBe(`:nth-match(div, 2)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use has-text', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<div>Hello world</div>
|
||||||
|
<a>Hello <span>world</span></a>
|
||||||
|
`);
|
||||||
|
expect(await generate(page, 'a')).toBe(`a:has-text("Hello world")`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should chain text after parent', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<div>Hello <span>world</span></div>
|
||||||
|
<a>Hello <span mark=1>world</span></a>
|
||||||
|
`);
|
||||||
|
expect(await generate(page, '[mark="1"]')).toBe(`a >> text=world`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use parent text', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<div>Hello <span>world</span></div>
|
||||||
|
<div>Goodbye <span mark=1>world</span></div>
|
||||||
|
`);
|
||||||
|
expect(await generate(page, '[mark="1"]')).toBe(`text=Goodbye world >> span`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should separate selectors by >>', async ({ page }) => {
|
it('should separate selectors by >>', async ({ page }) => {
|
||||||
|
|
@ -93,7 +160,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
<div>Text</div>
|
<div>Text</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
expect(await generate(page, '#id > div')).toBe('div[id=\"id\"] >> text=\"Text\"');
|
expect(await generate(page, '#id > div')).toBe('#id >> text=Text');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should trim long text', async ({ page }) => {
|
it('should trim long text', async ({ page }) => {
|
||||||
|
|
@ -105,12 +172,12 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
<div>Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on</div>
|
<div>Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
expect(await generate(page, '#id > div')).toBe('div[id=\"id\"] >> text=/.*Text that goes on and on and o.*/');
|
expect(await generate(page, '#id > div')).toBe(`#id >> text=Text that goes on and on and on and on and on and on and on and on and on and on`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use nested ordinals', async ({ page }) => {
|
it('should use nested ordinals', async ({ page }) => {
|
||||||
await page.setContent(`
|
await page.setContent(`
|
||||||
<a><b></b></a>
|
<a><c></c><c></c><c></c><c></c><c></c><b></b></a>
|
||||||
<a>
|
<a>
|
||||||
<b>
|
<b>
|
||||||
<c>
|
<c>
|
||||||
|
|
@ -122,7 +189,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
</a>
|
</a>
|
||||||
<a><b></b></a>
|
<a><b></b></a>
|
||||||
`);
|
`);
|
||||||
expect(await generate(page, 'c[mark="1"]')).toBe('//b[2]/c');
|
expect(await generate(page, 'c[mark="1"]')).toBe('b:nth-child(2) c');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not use input[value]', async ({ page }) => {
|
it('should not use input[value]', async ({ page }) => {
|
||||||
|
|
@ -131,7 +198,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
<input value="two" mark="1">
|
<input value="two" mark="1">
|
||||||
<input value="three">
|
<input value="three">
|
||||||
`);
|
`);
|
||||||
expect(await generate(page, 'input[mark="1"]')).toBe('//input[2]');
|
expect(await generate(page, 'input[mark="1"]')).toBe(':nth-match(input, 2)');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('should prioritise input element attributes correctly', () => {
|
describe('should prioritise input element attributes correctly', () => {
|
||||||
|
|
@ -141,7 +208,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
});
|
});
|
||||||
it('placeholder', async ({ page }) => {
|
it('placeholder', async ({ page }) => {
|
||||||
await page.setContent(`<input placeholder="foobar" type="text"/>`);
|
await page.setContent(`<input placeholder="foobar" type="text"/>`);
|
||||||
expect(await generate(page, 'input')).toBe('input[placeholder="foobar"]');
|
expect(await generate(page, 'input')).toBe('[placeholder="foobar"]');
|
||||||
});
|
});
|
||||||
it('type', async ({ page }) => {
|
it('type', async ({ page }) => {
|
||||||
await page.setContent(`<input type="text"/>`);
|
await page.setContent(`<input type="text"/>`);
|
||||||
|
|
@ -157,10 +224,10 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
span.textContent = 'Target';
|
span.textContent = 'Target';
|
||||||
shadowRoot.appendChild(span);
|
shadowRoot.appendChild(span);
|
||||||
});
|
});
|
||||||
expect(await generate(page, 'span')).toBe('text="Target"');
|
expect(await generate(page, 'span')).toBe('text=Target');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to css in shadow dom', async ({ page }) => {
|
it('should match in shadow dom', async ({ page }) => {
|
||||||
await page.setContent(`<div></div>`);
|
await page.setContent(`<div></div>`);
|
||||||
await page.$eval('div', div => {
|
await page.$eval('div', div => {
|
||||||
const shadowRoot = div.attachShadow({ mode: 'open' });
|
const shadowRoot = div.attachShadow({ mode: 'open' });
|
||||||
|
|
@ -170,7 +237,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
expect(await generate(page, 'input')).toBe('input');
|
expect(await generate(page, 'input')).toBe('input');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to css in deep shadow dom', async ({ page }) => {
|
it('should match in deep shadow dom', async ({ page }) => {
|
||||||
await page.setContent(`<div></div><div></div><div><input></div>`);
|
await page.setContent(`<div></div><div></div><div><input></div>`);
|
||||||
await page.$eval('div', div1 => {
|
await page.$eval('div', div1 => {
|
||||||
const shadowRoot1 = div1.attachShadow({ mode: 'open' });
|
const shadowRoot1 = div1.attachShadow({ mode: 'open' });
|
||||||
|
|
@ -185,7 +252,7 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
input2.setAttribute('value', 'foo');
|
input2.setAttribute('value', 'foo');
|
||||||
shadowRoot2.appendChild(input2);
|
shadowRoot2.appendChild(input2);
|
||||||
});
|
});
|
||||||
expect(await generate(page, 'input[value=foo]')).toBe('div div:nth-child(3) input');
|
expect(await generate(page, 'input[value=foo]')).toBe(':nth-match(input, 3)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work in dynamic iframes without navigation', async ({ page }) => {
|
it('should work in dynamic iframes without navigation', async ({ page }) => {
|
||||||
|
|
@ -203,6 +270,6 @@ describe('selector generator', (suite, { mode }) => {
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(await generate(frame, 'div')).toBe('text="Target"');
|
expect(await generate(frame, 'div')).toBe('text=Target');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue