Skip to content

Commit

Permalink
ember selector engine
Browse files Browse the repository at this point in the history
  • Loading branch information
lifeart committed Aug 25, 2021
1 parent 2453ca0 commit 97546de
Show file tree
Hide file tree
Showing 9 changed files with 96,239 additions and 4 deletions.
47 changes: 46 additions & 1 deletion docs/src/selectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,23 @@ methods accept [`param: selector`] as their first argument.
await page.ClickAsync("_vue=list-item[text *= 'milk' i]");
```
Learn more about [Vue selectors][vue].

- Ember selector (experimental)
```js
await page.click('_ember=LinkTo[text *= "milk" i]');
```
```java
page.click("_ember=LinkTo[text *= 'milk' i]");
```
```python async
await page.click("_ember=LinkTo[text *= "milk" i]")
```
```python sync
page.click("_ember=LinkTo[text *= 'milk' i]")
```
```csharp
await page.ClickAsync("_ember=LinkTo[text *= 'milk' i]");
```
Learn more about [Ember selectors][ember].


## Text selector
Expand Down Expand Up @@ -781,6 +797,34 @@ Vue selectors, as well as [Vue DevTools](https://chrome.google.com/webstore/deta
:::


## Ember selectors

:::note
Ember selectors are experimental and prefixed with `_`. The functionality might change in future.
:::

Ember selectors allow selecting elements by its component name and property values. The syntax is very similar to [attribute selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) and supports all attribute selector operators.

In ember selectors, component names are transcribed with **CamelCase**.

Selector examples:

- match by **component**: `_ember=LinkTo`
- match by component and **exact property value**, case-sensetive: `_ember=LinkTo[author = "Steven King"]`
- match by property value only, **case-insensetive**: `_ember=[author = "steven king" i]`
- match by component and **truthy property value**: `_ember=MyButton[enabled]`
- match by component and **boolean value**: `_ember=MyButton[enabled = false]`
- match by property **value substring**: `_ember=[author *= "King"]`
- match by component and **multiple properties**: `_ember=BookItem[author *= "king" i][year = 1990]`
- match by **nested** property value: `_ember=[some.nested.value = 12]`
- match by component and property value **prefix**: `_ember=BookItem[author ^= "Steven"]`
- match by component and property value **suffix**: `_ember=BookItem[author $= "Steven"]`



To find Ember element names in a tree use [Ember Inspector](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi?hl=en).


## id, data-testid, data-test-id, data-test selectors

Playwright supports a shorthand for selecting elements using certain attributes. Currently, only
Expand Down Expand Up @@ -1124,4 +1168,5 @@ await page.ClickAsync("//*[@id='tsf']/div[2]/div[1]/div[1]/div/div[2]/input");
[xpath]: #xpath-selectors
[react]: #react-selectors
[vue]: #vue-selectors
[ember]: #ember-selectors
[id]: #id-data-testid-data-test-id-data-test-selectors
217 changes: 217 additions & 0 deletions src/server/injected/emberSelectorEngine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { checkComponentAttribute, parseComponentSelector } from '../common/componentUtils';

interface IEmberAppInstance {
__container__: unknown;
_debugContainerKey?: string;
}

interface IInteralEmberComponent {
children: IInteralEmberComponent[];
args: {
named: Record<string, unknown>
},
bounds: {
firstNode: HTMLElement,
lastNode: HTMLElement,
parentElement: HTMLElement,
}
type: string;
name: string;
}

interface IEmberComponent {
name: string;
args?: Record<string, unknown>;
rootElements: HTMLElement[];
components: IEmberComponent[];
isComponent?: boolean;
}

function normalizeToAngleBracketComponent(name: string) {
const SIMPLE_DASHERIZE_REGEXP = /[a-z]|\/|-/g;
const ALPHA = /[A-Za-z0-9]/;

if (name.includes('.'))
return name;


return name.replace(SIMPLE_DASHERIZE_REGEXP, (char, index) => {
if (char === '/')
return '::';


if (index === 0 || !ALPHA.test(name[index - 1]))
return char.toUpperCase();


// Remove all occurrences of '-'s from the name that aren't starting with `-`
return char === '-' ? '' : char.toLowerCase();
});
}

function getEmber() {
let EmberCore;
const w = window as any;

try {
EmberCore = w.requireModule('ember')['default'];
} catch {
EmberCore = w.Ember;
}

return EmberCore;
}

function findEmberRoots(): IEmberAppInstance[] {

const EmberCore = getEmber();

if (!EmberCore)
return [];


const isEmberApp = (el: any) => el._debugContainerKey === 'application:main';

const app: IEmberAppInstance | null = Object.values(EmberCore.Application.NAMESPACES).find(isEmberApp) as IEmberAppInstance | null;

if (!app)
return [];


return [app];
}

function normalizeExtractedComponents(node: IEmberComponent) {
function cleanComponent(el: IEmberComponent) {
if (el.isComponent) {
delete el.isComponent;
if (!Object.keys(el.args || {}).length)
delete el.args;

}
}

const result = [];
if (node.isComponent) {
cleanComponent(node);
node.components.forEach((el: IEmberComponent) => {
cleanComponent(el);
});
result.push(node);
} else {
node.components.forEach((el: IEmberComponent) => {
const results = normalizeExtractedComponents(el);
result.push(...results);
});
}

return result;
}

function buildComponentsTree(appRoot: IEmberAppInstance): IEmberComponent[] {
const tree = getEmber()._captureRenderTree(appRoot.__container__);
const components = extractComponents(tree[0]);
const normalizedComponents = normalizeExtractedComponents(components[0]);
return normalizedComponents;
}


function findRoots(bounds: { firstNode: HTMLElement, lastNode: HTMLElement, parentElement: HTMLElement }) {
const { firstNode, lastNode, parentElement } = bounds;
const roots: ChildNode[] = [];
const closest = parentElement.childNodes;
if (firstNode === lastNode)
return [firstNode];

let start = null;
let end = null;
for (let i = 0; i < closest.length; i++) {
if (closest.item(i) === firstNode)
start = i;
else if (closest.item(i) === lastNode)
end = i;
}

if (start === null || end === null)
return [];


for (let i = start; i <= end; i++)
roots.push(closest.item(i));


return roots.filter((el: ChildNode) => {
if (el.nodeType === 3) {
if (el.nodeValue && el.nodeValue.trim() === '')
return false;

}
return el;
}) as HTMLElement[];
}

function extractComponents(node: IInteralEmberComponent) {
const components: IEmberComponent[] = node.children.map((el: IInteralEmberComponent) => {
const instance: IEmberComponent = {
isComponent: el.type === 'component',
name: normalizeToAngleBracketComponent(el.name),
args: el.args.named,
rootElements: findRoots(el.bounds),
components: extractComponents(el)
};
return instance;
});
return components;
}

function filterComponentsTree(treeNode: IEmberComponent, searchFn: (node: IEmberComponent) => boolean, result: IEmberComponent[] = []) {
if (searchFn(treeNode))
result.push(treeNode);
for (const child of treeNode.components)
filterComponentsTree(child, searchFn, result);
return result;
}

export const EmberEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const { name, attributes } = parseComponentSelector(selector);

const emberRoots = findEmberRoots();

const trees = emberRoots.map(emberRoot => buildComponentsTree(emberRoot)[0]);
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
if (name && treeNode.name !== name)
return false;
if (treeNode.rootElements.some(domNode => !scope.contains(domNode)))
return false;
for (const attr of attributes) {
if (!checkComponentAttribute(treeNode.args || {}, attr))
return false;
}
return true;
})).flat();
const allRootElements: Set<Element> = new Set();
for (const treeNode of treeNodes) {
for (const domNode of treeNode.rootElements)
allRootElements.add(domNode);
}
return [...allRootElements];
}
};
2 changes: 2 additions & 0 deletions src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { ReactEngine } from './reactSelectorEngine';
import { VueEngine } from './vueSelectorEngine';
import { EmberEngine } from './emberSelectorEngine';
import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser';
import { FatalDOMError } from '../common/domErrors';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
Expand Down Expand Up @@ -66,6 +67,7 @@ export class InjectedScript {
this._engines.set('xpath:light', XPathEngine);
this._engines.set('_react', ReactEngine);
this._engines.set('_vue', VueEngine);
this._engines.set('_ember', EmberEngine);
this._engines.set('text', this._createTextEngine(true));
this._engines.set('text:light', this._createTextEngine(false));
this._engines.set('id', this._createAttributeEngine('id', true));
Expand Down
4 changes: 2 additions & 2 deletions src/server/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class Selectors {
this._builtinEngines = new Set([
'css', 'css:light',
'xpath', 'xpath:light',
'_react', '_vue',
'_react', '_vue', '_ember',
'text', 'text:light',
'id', 'id:light',
'data-testid', 'data-testid:light',
Expand All @@ -48,7 +48,7 @@ export class Selectors {
'nth', 'visible'
]);
this._builtinEnginesInMainWorld = new Set([
'_react', '_vue',
'_react', '_vue', '_ember',
]);
this._engines = new Map();
}
Expand Down
Loading

0 comments on commit 97546de

Please sign in to comment.