mirror of
https://github.com/microsoft/playwright-cli.git
synced 2026-09-14 19:59:39 +08:00
feat(codegen): generate selectors in shadow dom (#92)
This commit is contained in:
@@ -27,6 +27,7 @@ export type ParsedSelector = {
|
||||
export interface InjectedScript {
|
||||
parseSelector(selector: string): ParsedSelector;
|
||||
engines: Set<string>;
|
||||
querySelector(selector: ParsedSelector, document: Document): Element | undefined;
|
||||
querySelectorAll(selector: ParsedSelector, document: Document): Element[];
|
||||
};
|
||||
|
||||
|
||||
+29
-14
@@ -144,7 +144,7 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _consumedDueWrongTarget(event: Event): boolean {
|
||||
if (this._activeModel && this._activeModel.elements[0] === event.target)
|
||||
if (this._activeModel && this._activeModel.elements[0] === deepEventTarget(event))
|
||||
return false;
|
||||
consumeEvent(event);
|
||||
return true;
|
||||
@@ -170,12 +170,13 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _shouldIgnoreMouseEvent(event: MouseEvent): boolean {
|
||||
const nodeName = (event.target as Element).nodeName;
|
||||
const target = deepEventTarget(event);
|
||||
const nodeName = target.nodeName;
|
||||
if (nodeName === 'SELECT')
|
||||
return true
|
||||
if (nodeName === 'INPUT' && ['date', 'checkbox'].includes((event.target as HTMLInputElement).type.toLowerCase()))
|
||||
return true
|
||||
return false
|
||||
return true;
|
||||
if (nodeName === 'INPUT' && ['date', 'checkbox'].includes((target as HTMLInputElement).type.toLowerCase()))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private _onMouseDown(event: MouseEvent) {
|
||||
@@ -194,23 +195,25 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onMouseMove(event: MouseEvent) {
|
||||
if (this._hoveredElement === event.target)
|
||||
const target = deepEventTarget(event);
|
||||
if (this._hoveredElement === target)
|
||||
return;
|
||||
this._hoveredElement = event.target as HTMLElement | null;
|
||||
this._hoveredElement = target;
|
||||
// Mouse moved -> mark last action as committed via committing a commit action.
|
||||
this._commitActionAndUpdateModelForHoveredElement();
|
||||
}
|
||||
|
||||
private _onMouseLeave(event: MouseEvent) {
|
||||
// Leaving iframe.
|
||||
if ((event.target as Node).nodeType === Node.DOCUMENT_NODE) {
|
||||
if (deepEventTarget(event).nodeType === Node.DOCUMENT_NODE) {
|
||||
this._hoveredElement = null;
|
||||
this._commitActionAndUpdateModelForHoveredElement();
|
||||
}
|
||||
}
|
||||
|
||||
private _onFocus(event: FocusEvent) {
|
||||
const result = document.activeElement ? this._consoleAPI.buildSelector(document.activeElement) : null;
|
||||
const activeElement = deepActiveElement(document);
|
||||
const result = activeElement ? this._consoleAPI.buildSelector(activeElement) : null;
|
||||
this._activeModel = result && result.selector ? result : null;
|
||||
if ((window as any)._highlightUpdatedForTest)
|
||||
(window as any)._highlightUpdatedForTest(result ? result.selector : null);
|
||||
@@ -309,8 +312,9 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onInput(event: Event) {
|
||||
if (['INPUT', 'TEXTAREA'].includes((event.target as Element).nodeName)) {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
const target = deepEventTarget(event);
|
||||
if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
|
||||
const inputElement = target as HTMLInputElement;
|
||||
const elementType = (inputElement.type || '').toLowerCase()
|
||||
if (elementType === 'checkbox') {
|
||||
if (this._actionInProgress(event))
|
||||
@@ -346,8 +350,8 @@ export class Recorder {
|
||||
});
|
||||
}
|
||||
|
||||
if ((event.target as Element).nodeName === 'SELECT') {
|
||||
const selectElement = event.target as HTMLSelectElement;
|
||||
if (target.nodeName === 'SELECT') {
|
||||
const selectElement = target as HTMLSelectElement;
|
||||
if (this._actionInProgress(event))
|
||||
return;
|
||||
this._performAction({
|
||||
@@ -423,6 +427,17 @@ export class Recorder {
|
||||
}
|
||||
}
|
||||
|
||||
function deepEventTarget(event: Event): HTMLElement {
|
||||
return event.composedPath()[0] as HTMLElement;
|
||||
}
|
||||
|
||||
function deepActiveElement(document: Document): Element | null {
|
||||
let activeElement = document.activeElement;
|
||||
while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement)
|
||||
activeElement = activeElement.shadowRoot.activeElement;
|
||||
return activeElement;
|
||||
};
|
||||
|
||||
function modifiersForEvent(event: MouseEvent | KeyboardEvent): number {
|
||||
return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InjectedScript } from './consoleApi';
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
@@ -16,12 +15,13 @@ import { InjectedScript } from './consoleApi';
|
||||
*/
|
||||
|
||||
import { XPathEngine } from './xpathSelectorEngine';
|
||||
import { InjectedScript } from './consoleApi';
|
||||
|
||||
export function buildSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } {
|
||||
const path: SelectorToken[] = [];
|
||||
let numberOfMatchingElements = Number.MAX_SAFE_INTEGER;
|
||||
for (let element: Element | null = targetElement; element && element !== document.documentElement; element = element.parentElement) {
|
||||
const selector = buildSelectorCandidate(injectedScript, element);
|
||||
for (let element: Element | null = targetElement; element && element !== document.documentElement; element = parentElementOrShadowHost(element)) {
|
||||
const selector = buildSelectorCandidate(element);
|
||||
if (!selector)
|
||||
continue;
|
||||
const fullSelector = joinSelector([selector, ...path]);
|
||||
@@ -41,15 +41,17 @@ export function buildSelector(injectedScript: InjectedScript, targetElement: Ele
|
||||
selector: '/html',
|
||||
elements: [document.documentElement]
|
||||
};
|
||||
const xpathSelector = XPathEngine.create(document.documentElement, targetElement, 'default')!;
|
||||
const parsedSelector = injectedScript.parseSelector(xpathSelector);
|
||||
const selector =
|
||||
XPathEngine.create(document.documentElement, targetElement, 'default') ||
|
||||
cssSelectorForElement(injectedScript, targetElement);
|
||||
const parsedSelector = injectedScript.parseSelector(selector);
|
||||
return {
|
||||
selector: xpathSelector,
|
||||
selector,
|
||||
elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument)
|
||||
};
|
||||
}
|
||||
|
||||
function buildSelectorCandidate(injectedScript: InjectedScript, element: Element): SelectorToken | null {
|
||||
function buildSelectorCandidate(element: Element): SelectorToken | null {
|
||||
const nodeName = element.nodeName.toLowerCase();
|
||||
for (const attribute of ['data-testid', 'data-test-id', 'data-test']) {
|
||||
if (element.hasAttribute(attribute))
|
||||
@@ -83,6 +85,78 @@ function buildSelectorCandidate(injectedScript: InjectedScript, element: Element
|
||||
return null;
|
||||
}
|
||||
|
||||
function parentElementOrShadowHost(element: Element): Element | null {
|
||||
if (element.parentElement)
|
||||
return element.parentElement;
|
||||
if (!element.parentNode)
|
||||
return null;
|
||||
if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host)
|
||||
return (element.parentNode as ShadowRoot).host;
|
||||
return null;
|
||||
}
|
||||
|
||||
function cssSelectorForElement(injectedScript: InjectedScript, targetElement: Element): string {
|
||||
const root: Node = targetElement.ownerDocument;
|
||||
const tokens: string[] = [];
|
||||
|
||||
function uniqueCSSSelector(prefix?: string): string | undefined {
|
||||
const path = tokens.slice();
|
||||
if (prefix)
|
||||
path.unshift(prefix);
|
||||
const selector = path.join(' ');
|
||||
const parsedSelector = injectedScript.parseSelector(selector);
|
||||
const node = injectedScript.querySelector(parsedSelector, targetElement.ownerDocument);
|
||||
return node === targetElement ? selector : undefined;
|
||||
}
|
||||
|
||||
for (let element: Element | null = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) {
|
||||
const nodeName = element.nodeName.toLowerCase();
|
||||
|
||||
// Element ID is the strongest signal, use it.
|
||||
let bestTokenForLevel: string = '';
|
||||
if (element.id) {
|
||||
const token = /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(element.id) ? '#' + element.id : `[id="${element.id}"]`;
|
||||
const selector = uniqueCSSSelector(token);
|
||||
if (selector)
|
||||
return selector;
|
||||
bestTokenForLevel = token;
|
||||
}
|
||||
|
||||
const parent = element.parentNode as (Element | ShadowRoot);
|
||||
|
||||
// Combine class names until unique.
|
||||
const classes = Array.from(element.classList);
|
||||
for (let i = 0; i < classes.length; ++i) {
|
||||
const token = '.' + classes.slice(0, i + 1).join('.');
|
||||
const selector = uniqueCSSSelector(token);
|
||||
if (selector)
|
||||
return selector;
|
||||
// Even if not unique, does this subset of classes uniquely identify node as a child?
|
||||
if (!bestTokenForLevel && parent) {
|
||||
const sameClassSiblings = parent.querySelectorAll(token);
|
||||
if (sameClassSiblings.length === 1)
|
||||
bestTokenForLevel = token;
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinal is the weakest signal.
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children);
|
||||
const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName);
|
||||
const token = sameTagSiblings.indexOf(element) === 0 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`;
|
||||
const selector = uniqueCSSSelector(token);
|
||||
if (selector)
|
||||
return selector;
|
||||
if (!bestTokenForLevel)
|
||||
bestTokenForLevel = token;
|
||||
} else if (!bestTokenForLevel) {
|
||||
bestTokenForLevel = nodeName;
|
||||
}
|
||||
tokens.unshift(bestTokenForLevel);
|
||||
}
|
||||
return uniqueCSSSelector()!;
|
||||
}
|
||||
|
||||
function textSelectorForElement(node: Node): string | null {
|
||||
const maxLength = 30;
|
||||
let needsRegex = false;
|
||||
|
||||
+45
-2
@@ -132,7 +132,7 @@ it('should not use input[value]', async ({ recorder }) => {
|
||||
expect(selector).toBe('//input[2]');
|
||||
});
|
||||
|
||||
describe("should prioritise input element attributes correctly", () => {
|
||||
describe('should prioritise input element attributes correctly', () => {
|
||||
it('name', async ({ recorder }) => {
|
||||
await recorder.setContentAndWait(`<input name="foobar" type="text"/>`);
|
||||
expect(await recorder.hoverOverElement('input')).toBe('input[name="foobar"]');
|
||||
@@ -145,4 +145,47 @@ describe("should prioritise input element attributes correctly", () => {
|
||||
await recorder.setContentAndWait(`<input type="text"/>`);
|
||||
expect(await recorder.hoverOverElement('input')).toBe('input[type="text"]');
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
it('should find text in shadow dom', async ({ recorder }) => {
|
||||
await recorder.setContentAndWait(`<div></div>`);
|
||||
await recorder.page.$eval('div', div => {
|
||||
const shadowRoot = div.attachShadow({ mode: 'open' });
|
||||
const span = document.createElement('span');
|
||||
span.textContent = 'Target';
|
||||
shadowRoot.appendChild(span);
|
||||
});
|
||||
const selector = await recorder.hoverOverElement('span');
|
||||
expect(selector).toBe('text="Target"');
|
||||
});
|
||||
|
||||
it('should fallback to css in shadow dom', async ({ recorder }) => {
|
||||
await recorder.setContentAndWait(`<div></div>`);
|
||||
await recorder.page.$eval('div', div => {
|
||||
const shadowRoot = div.attachShadow({ mode: 'open' });
|
||||
const input = document.createElement('input');
|
||||
shadowRoot.appendChild(input);
|
||||
});
|
||||
const selector = await recorder.hoverOverElement('input');
|
||||
expect(selector).toBe('input');
|
||||
});
|
||||
|
||||
it('should fallback to css in deep shadow dom', async ({ recorder }) => {
|
||||
recorder.page.on('console', console.log);
|
||||
await recorder.setContentAndWait(`<div></div><div></div><div><input></div>`);
|
||||
await recorder.page.$eval('div', div1 => {
|
||||
const shadowRoot1 = div1.attachShadow({ mode: 'open' });
|
||||
const input1 = document.createElement('input');
|
||||
shadowRoot1.appendChild(input1);
|
||||
const divExtra3 = document.createElement('div');
|
||||
shadowRoot1.append(divExtra3);
|
||||
const div2 = document.createElement('div');
|
||||
shadowRoot1.append(div2);
|
||||
const shadowRoot2 = div2.attachShadow({ mode: 'open' });
|
||||
const input2 = document.createElement('input');
|
||||
input2.setAttribute('value', 'foo');
|
||||
shadowRoot2.appendChild(input2);
|
||||
});
|
||||
const selector = await recorder.hoverOverElement('input[value=foo]');
|
||||
expect(selector).toBe('div div:nth-child(3) input');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user