feat(codegen): generate selectors in shadow dom (#92)

This commit is contained in:
Dmitry Gozman
2020-10-05 09:54:59 -07:00
committed by GitHub
parent 04d682919e
commit 76ba600ccf
4 changed files with 156 additions and 23 deletions
+1
View File
@@ -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
View File
@@ -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);
}
+81 -7
View File
@@ -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
View File
@@ -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');
});