2024-06-05 06:26:13 +05:30
|
|
|
|
import { Page } from "playwright";
|
|
|
|
|
|
import { Action, ActionType, Coordinates, TagName } from "../types";
|
2024-07-31 22:46:19 +05:30
|
|
|
|
import { WhereWhatPair, WorkflowFile } from "maxun-core";
|
2024-06-05 06:26:13 +05:30
|
|
|
|
import logger from "../logger";
|
|
|
|
|
|
import { getBestSelectorForAction } from "./utils";
|
|
|
|
|
|
|
2024-06-05 06:34:46 +05:30
|
|
|
|
type Workflow = WorkflowFile["workflow"];
|
|
|
|
|
|
|
2024-06-05 06:26:13 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Returns a {@link Rectangle} object representing
|
|
|
|
|
|
* the coordinates, width, height and corner points of the element.
|
|
|
|
|
|
* If an element is not found, returns null.
|
|
|
|
|
|
* @param page The page instance.
|
|
|
|
|
|
* @param coordinates Coordinates of an element.
|
|
|
|
|
|
* @category WorkflowManagement-Selectors
|
|
|
|
|
|
* @returns {Promise<Rectangle|undefined|null>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const getRect = async (page: Page, coordinates: Coordinates) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const rect = await page.evaluate(
|
|
|
|
|
|
async ({ x, y }) => {
|
|
|
|
|
|
const el = document.elementFromPoint(x, y) as HTMLElement;
|
2024-06-05 06:27:17 +05:30
|
|
|
|
if (el) {
|
2024-06-06 05:14:18 +05:30
|
|
|
|
const { parentElement } = el;
|
|
|
|
|
|
// Match the logic in recorder.ts for link clicks
|
|
|
|
|
|
const element = parentElement?.tagName === 'A' ? parentElement : el;
|
|
|
|
|
|
const rectangle = element?.getBoundingClientRect();
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
|
|
if (rectangle) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: rectangle.x,
|
|
|
|
|
|
y: rectangle.y,
|
|
|
|
|
|
width: rectangle.width,
|
|
|
|
|
|
height: rectangle.height,
|
|
|
|
|
|
top: rectangle.top,
|
|
|
|
|
|
right: rectangle.right,
|
|
|
|
|
|
bottom: rectangle.bottom,
|
|
|
|
|
|
left: rectangle.left,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2024-06-05 06:32:45 +05:30
|
|
|
|
}
|
2024-06-06 05:14:18 +05:30
|
|
|
|
},
|
2024-06-05 06:32:45 +05:30
|
|
|
|
{ x: coordinates.x, y: coordinates.y },
|
2024-06-05 06:26:13 +05:30
|
|
|
|
);
|
2024-06-05 06:32:45 +05:30
|
|
|
|
return rect;
|
2024-06-05 06:26:13 +05:30
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const { message, stack } = error as Error;
|
|
|
|
|
|
logger.log('error', `Error while retrieving selector: ${message}`);
|
|
|
|
|
|
logger.log('error', `Stack: ${stack}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-06-05 07:05:53 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Checks the basic info about an element and returns a {@link BaseActionInfo} object.
|
|
|
|
|
|
* If the element is not found, returns undefined.
|
|
|
|
|
|
* @param page The page instance.
|
|
|
|
|
|
* @param coordinates Coordinates of an element.
|
|
|
|
|
|
* @category WorkflowManagement-Selectors
|
|
|
|
|
|
* @returns {Promise<BaseActionInfo|undefined>}
|
|
|
|
|
|
*/
|
2024-06-05 07:05:20 +05:30
|
|
|
|
export const getElementInformation = async (
|
|
|
|
|
|
page: Page,
|
|
|
|
|
|
coordinates: Coordinates
|
|
|
|
|
|
) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const elementInfo = await page.evaluate(
|
|
|
|
|
|
async ({ x, y }) => {
|
|
|
|
|
|
const el = document.elementFromPoint(x, y) as HTMLElement;
|
2024-06-06 05:14:18 +05:30
|
|
|
|
if (el) {
|
2024-06-05 07:05:20 +05:30
|
|
|
|
const { parentElement } = el;
|
|
|
|
|
|
const element = parentElement?.tagName === 'A' ? parentElement : el;
|
2024-07-15 20:53:55 +05:30
|
|
|
|
|
|
|
|
|
|
let info: {
|
|
|
|
|
|
tagName: string;
|
|
|
|
|
|
hasOnlyText?: boolean;
|
|
|
|
|
|
innerText?: string;
|
|
|
|
|
|
url?: string;
|
|
|
|
|
|
imageUrl?: string;
|
|
|
|
|
|
} = {
|
2024-06-05 07:05:20 +05:30
|
|
|
|
tagName: element?.tagName ?? '',
|
2024-07-15 19:29:17 +05:30
|
|
|
|
};
|
2024-07-15 20:53:55 +05:30
|
|
|
|
|
|
|
|
|
|
if (element?.tagName === 'A') {
|
|
|
|
|
|
info.url = (element as HTMLAnchorElement).href;
|
|
|
|
|
|
info.innerText = element.innerText ?? '';
|
|
|
|
|
|
} else if (element?.tagName === 'IMG') {
|
|
|
|
|
|
info.imageUrl = (element as HTMLImageElement).src;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
info.hasOnlyText = element?.children?.length === 0 &&
|
|
|
|
|
|
element?.innerText?.length > 0;
|
|
|
|
|
|
info.innerText = element?.innerText ?? '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return info;
|
2024-06-05 07:05:20 +05:30
|
|
|
|
}
|
2024-07-15 19:29:17 +05:30
|
|
|
|
return null;
|
2024-06-05 07:05:20 +05:30
|
|
|
|
},
|
|
|
|
|
|
{ x: coordinates.x, y: coordinates.y },
|
|
|
|
|
|
);
|
2024-07-15 19:29:17 +05:30
|
|
|
|
|
2024-08-07 00:28:45 +05:30
|
|
|
|
// if (elementInfo) {
|
|
|
|
|
|
// if (elementInfo.tagName === 'A') {
|
|
|
|
|
|
// if (elementInfo.innerText) {
|
|
|
|
|
|
// console.log(`Link text: ${elementInfo.innerText}, URL: ${elementInfo.url}`);
|
|
|
|
|
|
// } else {
|
|
|
|
|
|
// console.log(`URL: ${elementInfo.url}`);
|
|
|
|
|
|
// }
|
|
|
|
|
|
// } else if (elementInfo.tagName === 'IMG') {
|
|
|
|
|
|
// console.log(`Image URL: ${elementInfo.imageUrl}`);
|
|
|
|
|
|
// } else {
|
|
|
|
|
|
// console.log(`Element innerText: ${elementInfo.innerText}`);
|
|
|
|
|
|
// }
|
|
|
|
|
|
// }
|
2024-08-07 05:32:00 +05:30
|
|
|
|
|
2024-06-05 07:05:20 +05:30
|
|
|
|
return elementInfo;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
const { message, stack } = error as Error;
|
2024-07-15 19:29:17 +05:30
|
|
|
|
console.error('Error while retrieving selector:', message);
|
|
|
|
|
|
console.error('Stack:', stack);
|
2024-06-05 07:05:20 +05:30
|
|
|
|
}
|
2024-07-15 19:29:17 +05:30
|
|
|
|
};
|
|
|
|
|
|
|
2024-06-05 07:05:20 +05:30
|
|
|
|
|
2024-06-05 10:34:17 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Returns the best and unique css {@link Selectors} for the element on the page.
|
|
|
|
|
|
* Internally uses a finder function from https://github.com/antonmedv/finder/blob/master/finder.ts
|
|
|
|
|
|
* available as a npm package: @medv/finder
|
|
|
|
|
|
*
|
|
|
|
|
|
* The finder needs to be executed and defined inside a browser context. Meaning,
|
|
|
|
|
|
* the code needs to be available inside a page evaluate function.
|
|
|
|
|
|
* @param page The page instance.
|
|
|
|
|
|
* @param coordinates Coordinates of an element.
|
|
|
|
|
|
* @category WorkflowManagement-Selectors
|
|
|
|
|
|
* @returns {Promise<Selectors|null|undefined>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const getSelectors = async (page: Page, coordinates: Coordinates) => {
|
|
|
|
|
|
try {
|
2024-06-06 05:14:18 +05:30
|
|
|
|
const selectors: any = await page.evaluate(async ({ x, y }) => {
|
|
|
|
|
|
// version @medv/finder
|
|
|
|
|
|
// https://github.com/antonmedv/finder/blob/master/finder.ts
|
|
|
|
|
|
|
|
|
|
|
|
type Node = {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
penalty: number;
|
|
|
|
|
|
level?: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type Path = Node[];
|
|
|
|
|
|
|
|
|
|
|
|
enum Limit {
|
|
|
|
|
|
All,
|
|
|
|
|
|
Two,
|
|
|
|
|
|
One,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Options = {
|
|
|
|
|
|
root: Element;
|
|
|
|
|
|
idName: (name: string) => boolean;
|
|
|
|
|
|
className: (name: string) => boolean;
|
|
|
|
|
|
tagName: (name: string) => boolean;
|
|
|
|
|
|
attr: (name: string, value: string) => boolean;
|
|
|
|
|
|
seedMinLength: number;
|
|
|
|
|
|
optimizedMinLength: number;
|
|
|
|
|
|
threshold: number;
|
|
|
|
|
|
maxNumberOfTries: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let config: Options;
|
|
|
|
|
|
|
|
|
|
|
|
let rootDocument: Document | Element;
|
|
|
|
|
|
|
|
|
|
|
|
function finder(input: Element, options?: Partial<Options>) {
|
|
|
|
|
|
if (input.nodeType !== Node.ELEMENT_NODE) {
|
|
|
|
|
|
throw new Error(`Can't generate CSS selector for non-element node type.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ('html' === input.tagName.toLowerCase()) {
|
|
|
|
|
|
return 'html';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const defaults: Options = {
|
|
|
|
|
|
root: document.body,
|
|
|
|
|
|
idName: (name: string) => true,
|
|
|
|
|
|
className: (name: string) => true,
|
|
|
|
|
|
tagName: (name: string) => true,
|
|
|
|
|
|
attr: (name: string, value: string) => false,
|
|
|
|
|
|
seedMinLength: 1,
|
|
|
|
|
|
optimizedMinLength: 2,
|
|
|
|
|
|
threshold: 1000,
|
|
|
|
|
|
maxNumberOfTries: 10000,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
config = { ...defaults, ...options };
|
|
|
|
|
|
|
|
|
|
|
|
rootDocument = findRootDocument(config.root, defaults);
|
|
|
|
|
|
|
|
|
|
|
|
let path = bottomUpSearch(input, Limit.All, () =>
|
|
|
|
|
|
bottomUpSearch(input, Limit.Two, () => bottomUpSearch(input, Limit.One))
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (path) {
|
|
|
|
|
|
const optimized = sort(optimize(path, input));
|
|
|
|
|
|
|
|
|
|
|
|
if (optimized.length > 0) {
|
|
|
|
|
|
path = optimized[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return selector(path);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error(`Selector was not found.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findRootDocument(rootNode: Element | Document, defaults: Options) {
|
|
|
|
|
|
if (rootNode.nodeType === Node.DOCUMENT_NODE) {
|
|
|
|
|
|
return rootNode;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (rootNode === defaults.root) {
|
|
|
|
|
|
return rootNode.ownerDocument as Document;
|
|
|
|
|
|
}
|
|
|
|
|
|
return rootNode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bottomUpSearch(
|
|
|
|
|
|
input: Element,
|
|
|
|
|
|
limit: Limit,
|
|
|
|
|
|
fallback?: () => Path | null
|
|
|
|
|
|
): Path | null {
|
|
|
|
|
|
let path: Path | null = null;
|
|
|
|
|
|
let stack: Node[][] = [];
|
|
|
|
|
|
let current: Element | null = input;
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
|
|
|
|
|
|
while (current && current !== config.root.parentElement) {
|
|
|
|
|
|
let level: Node[] = maybe(id(current)) ||
|
|
|
|
|
|
maybe(...attr(current)) ||
|
|
|
|
|
|
maybe(...classNames(current)) ||
|
|
|
|
|
|
maybe(tagName(current)) || [any()];
|
|
|
|
|
|
|
|
|
|
|
|
const nth = index(current);
|
|
|
|
|
|
|
|
|
|
|
|
if (limit === Limit.All) {
|
|
|
|
|
|
if (nth) {
|
|
|
|
|
|
level = level.concat(
|
|
|
|
|
|
level.filter(dispensableNth).map((node) => nthChild(node, nth))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (limit === Limit.Two) {
|
|
|
|
|
|
level = level.slice(0, 1);
|
|
|
|
|
|
|
|
|
|
|
|
if (nth) {
|
|
|
|
|
|
level = level.concat(
|
|
|
|
|
|
level.filter(dispensableNth).map((node) => nthChild(node, nth))
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (limit === Limit.One) {
|
|
|
|
|
|
const [node] = (level = level.slice(0, 1));
|
|
|
|
|
|
|
|
|
|
|
|
if (nth && dispensableNth(node)) {
|
|
|
|
|
|
level = [nthChild(node, nth)];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (let node of level) {
|
|
|
|
|
|
node.level = i;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stack.push(level);
|
|
|
|
|
|
|
|
|
|
|
|
if (stack.length >= config.seedMinLength) {
|
|
|
|
|
|
path = findUniquePath(stack, fallback);
|
|
|
|
|
|
if (path) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
current = current.parentElement;
|
|
|
|
|
|
i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!path) {
|
|
|
|
|
|
path = findUniquePath(stack, fallback);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return path;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findUniquePath(
|
|
|
|
|
|
stack: Node[][],
|
|
|
|
|
|
fallback?: () => Path | null
|
|
|
|
|
|
): Path | null {
|
|
|
|
|
|
const paths = sort(combinations(stack));
|
|
|
|
|
|
|
|
|
|
|
|
if (paths.length > config.threshold) {
|
|
|
|
|
|
return fallback ? fallback() : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (let candidate of paths) {
|
|
|
|
|
|
if (unique(candidate)) {
|
|
|
|
|
|
return candidate;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selector(path: Path): string {
|
|
|
|
|
|
let node = path[0];
|
|
|
|
|
|
let query = node.name;
|
|
|
|
|
|
for (let i = 1; i < path.length; i++) {
|
|
|
|
|
|
const level = path[i].level || 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (node.level === level - 1) {
|
|
|
|
|
|
query = `${path[i].name} > ${query}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
query = `${path[i].name} ${query}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
node = path[i];
|
|
|
|
|
|
}
|
|
|
|
|
|
return query;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function penalty(path: Path): number {
|
|
|
|
|
|
return path.map((node) => node.penalty).reduce((acc, i) => acc + i, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function unique(path: Path) {
|
|
|
|
|
|
switch (rootDocument.querySelectorAll(selector(path)).length) {
|
|
|
|
|
|
case 0:
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Can't select any node with this selector: ${selector(path)}`
|
|
|
|
|
|
);
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
return true;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function id(input: Element): Node | null {
|
|
|
|
|
|
const elementId = input.getAttribute('id');
|
|
|
|
|
|
if (elementId && config.idName(elementId)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: '#' + cssesc(elementId, { isIdentifier: true }),
|
|
|
|
|
|
penalty: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function attr(input: Element): Node[] {
|
|
|
|
|
|
const attrs = Array.from(input.attributes).filter((attr) =>
|
|
|
|
|
|
config.attr(attr.name, attr.value)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return attrs.map(
|
|
|
|
|
|
(attr): Node => ({
|
|
|
|
|
|
name:
|
|
|
|
|
|
'[' +
|
|
|
|
|
|
cssesc(attr.name, { isIdentifier: true }) +
|
|
|
|
|
|
'="' +
|
|
|
|
|
|
cssesc(attr.value) +
|
|
|
|
|
|
'"]',
|
|
|
|
|
|
penalty: 0.5,
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function classNames(input: Element): Node[] {
|
|
|
|
|
|
const names = Array.from(input.classList).filter(config.className);
|
|
|
|
|
|
|
|
|
|
|
|
return names.map(
|
|
|
|
|
|
(name): Node => ({
|
|
|
|
|
|
name: '.' + cssesc(name, { isIdentifier: true }),
|
|
|
|
|
|
penalty: 1,
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tagName(input: Element): Node | null {
|
|
|
|
|
|
const name = input.tagName.toLowerCase();
|
|
|
|
|
|
if (config.tagName(name)) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
name,
|
|
|
|
|
|
penalty: 2,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function any(): Node {
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: '*',
|
|
|
|
|
|
penalty: 3,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function index(input: Element): number | null {
|
|
|
|
|
|
const parent = input.parentNode;
|
|
|
|
|
|
if (!parent) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let child = parent.firstChild;
|
|
|
|
|
|
if (!child) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
while (child) {
|
|
|
|
|
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
|
|
|
|
i++;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (child === input) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
child = child.nextSibling;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return i;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function nthChild(node: Node, i: number): Node {
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: node.name + `:nth-child(${i})`,
|
|
|
|
|
|
penalty: node.penalty + 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function dispensableNth(node: Node) {
|
|
|
|
|
|
return node.name !== 'html' && !node.name.startsWith('#');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function maybe(...level: (Node | null)[]): Node[] | null {
|
|
|
|
|
|
const list = level.filter(notEmpty);
|
|
|
|
|
|
if (list.length > 0) {
|
|
|
|
|
|
return list;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function notEmpty<T>(value: T | null | undefined): value is T {
|
|
|
|
|
|
return value !== null && value !== undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function* combinations(stack: Node[][], path: Node[] = []): Generator<Node[]> {
|
|
|
|
|
|
if (stack.length > 0) {
|
|
|
|
|
|
for (let node of stack[0]) {
|
|
|
|
|
|
yield* combinations(stack.slice(1, stack.length), path.concat(node));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
yield path;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sort(paths: Iterable<Path>): Path[] {
|
|
|
|
|
|
return Array.from(paths).sort((a, b) => penalty(a) - penalty(b));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Scope = {
|
|
|
|
|
|
counter: number;
|
|
|
|
|
|
visited: Map<string, boolean>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function* optimize(
|
|
|
|
|
|
path: Path,
|
|
|
|
|
|
input: Element,
|
|
|
|
|
|
scope: Scope = {
|
|
|
|
|
|
counter: 0,
|
|
|
|
|
|
visited: new Map<string, boolean>(),
|
|
|
|
|
|
}
|
|
|
|
|
|
): Generator<Node[]> {
|
|
|
|
|
|
if (path.length > 2 && path.length > config.optimizedMinLength) {
|
|
|
|
|
|
for (let i = 1; i < path.length - 1; i++) {
|
|
|
|
|
|
if (scope.counter > config.maxNumberOfTries) {
|
|
|
|
|
|
return; // Okay At least I tried!
|
|
|
|
|
|
}
|
|
|
|
|
|
scope.counter += 1;
|
|
|
|
|
|
const newPath = [...path];
|
|
|
|
|
|
newPath.splice(i, 1);
|
|
|
|
|
|
const newPathKey = selector(newPath);
|
|
|
|
|
|
if (scope.visited.has(newPathKey)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (unique(newPath) && same(newPath, input)) {
|
|
|
|
|
|
yield newPath;
|
|
|
|
|
|
scope.visited.set(newPathKey, true);
|
|
|
|
|
|
yield* optimize(newPath, input, scope);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function same(path: Path, input: Element) {
|
|
|
|
|
|
return rootDocument.querySelector(selector(path)) === input;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const regexAnySingleEscape = /[ -,\.\/:-@\[-\^`\{-~]/;
|
|
|
|
|
|
const regexSingleEscape = /[ -,\.\/:-@\[\]\^`\{-~]/;
|
|
|
|
|
|
const regexExcessiveSpaces =
|
|
|
|
|
|
/(^|\\+)?(\\[A-F0-9]{1,6})\x20(?![a-fA-F0-9\x20])/g;
|
|
|
|
|
|
|
|
|
|
|
|
const defaultOptions = {
|
|
|
|
|
|
escapeEverything: false,
|
|
|
|
|
|
isIdentifier: false,
|
|
|
|
|
|
quotes: 'single',
|
|
|
|
|
|
wrap: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function cssesc(string: string, opt: Partial<typeof defaultOptions> = {}) {
|
|
|
|
|
|
const options = { ...defaultOptions, ...opt };
|
|
|
|
|
|
if (options.quotes != 'single' && options.quotes != 'double') {
|
|
|
|
|
|
options.quotes = 'single';
|
|
|
|
|
|
}
|
|
|
|
|
|
const quote = options.quotes == 'double' ? '"' : "'";
|
|
|
|
|
|
const isIdentifier = options.isIdentifier;
|
|
|
|
|
|
|
|
|
|
|
|
const firstChar = string.charAt(0);
|
|
|
|
|
|
let output = '';
|
|
|
|
|
|
let counter = 0;
|
|
|
|
|
|
const length = string.length;
|
|
|
|
|
|
while (counter < length) {
|
|
|
|
|
|
const character = string.charAt(counter++);
|
|
|
|
|
|
let codePoint = character.charCodeAt(0);
|
|
|
|
|
|
let value: string | undefined = void 0;
|
|
|
|
|
|
// If it’s not a printable ASCII character…
|
|
|
|
|
|
if (codePoint < 0x20 || codePoint > 0x7e) {
|
|
|
|
|
|
if (codePoint >= 0xd800 && codePoint <= 0xdbff && counter < length) {
|
|
|
|
|
|
// It’s a high surrogate, and there is a next character.
|
|
|
|
|
|
const extra = string.charCodeAt(counter++);
|
|
|
|
|
|
if ((extra & 0xfc00) == 0xdc00) {
|
|
|
|
|
|
// next character is low surrogate
|
|
|
|
|
|
codePoint = ((codePoint & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// It’s an unmatched surrogate; only append this code unit, in case
|
|
|
|
|
|
// the next code unit is the high surrogate of a surrogate pair.
|
|
|
|
|
|
counter--;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
value = '\\' + codePoint.toString(16).toUpperCase() + ' ';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (options.escapeEverything) {
|
|
|
|
|
|
if (regexAnySingleEscape.test(character)) {
|
|
|
|
|
|
value = '\\' + character;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
value = '\\' + codePoint.toString(16).toUpperCase() + ' ';
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (/[\t\n\f\r\x0B]/.test(character)) {
|
|
|
|
|
|
value = '\\' + codePoint.toString(16).toUpperCase() + ' ';
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
character == '\\' ||
|
|
|
|
|
|
(!isIdentifier &&
|
|
|
|
|
|
((character == '"' && quote == character) ||
|
|
|
|
|
|
(character == "'" && quote == character))) ||
|
|
|
|
|
|
(isIdentifier && regexSingleEscape.test(character))
|
|
|
|
|
|
) {
|
|
|
|
|
|
value = '\\' + character;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
value = character;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
output += value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isIdentifier) {
|
|
|
|
|
|
if (/^-[-\d]/.test(output)) {
|
|
|
|
|
|
output = '\\-' + output.slice(1);
|
|
|
|
|
|
} else if (/\d/.test(firstChar)) {
|
|
|
|
|
|
output = '\\3' + firstChar + ' ' + output.slice(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remove spaces after `\HEX` escapes that are not followed by a hex digit,
|
|
|
|
|
|
// since they’re redundant. Note that this is only possible if the escape
|
|
|
|
|
|
// sequence isn’t preceded by an odd number of backslashes.
|
|
|
|
|
|
output = output.replace(regexExcessiveSpaces, function ($0, $1, $2) {
|
|
|
|
|
|
if ($1 && $1.length % 2) {
|
|
|
|
|
|
// It’s not safe to remove the space, so don’t.
|
|
|
|
|
|
return $0;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Strip the space.
|
|
|
|
|
|
return ($1 || '') + $2;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!isIdentifier && options.wrap) {
|
|
|
|
|
|
return quote + output + quote;
|
|
|
|
|
|
}
|
|
|
|
|
|
return output;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const genSelectors = (element: HTMLElement | null) => {
|
|
|
|
|
|
if (element == null) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const href = element.getAttribute('href');
|
|
|
|
|
|
|
|
|
|
|
|
let generalSelector = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
generalSelector = finder(element);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let attrSelector = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
attrSelector = finder(element, { attr: () => true });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hrefSelector = genSelectorForAttributes(element, ['href']);
|
|
|
|
|
|
const formSelector = genSelectorForAttributes(element, [
|
|
|
|
|
|
'name',
|
|
|
|
|
|
'placeholder',
|
|
|
|
|
|
'for',
|
|
|
|
|
|
]);
|
|
|
|
|
|
const accessibilitySelector = genSelectorForAttributes(element, [
|
|
|
|
|
|
'aria-label',
|
|
|
|
|
|
'alt',
|
|
|
|
|
|
'title',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const testIdSelector = genSelectorForAttributes(element, [
|
|
|
|
|
|
'data-testid',
|
|
|
|
|
|
'data-test-id',
|
|
|
|
|
|
'data-testing',
|
|
|
|
|
|
'data-test',
|
|
|
|
|
|
'data-qa',
|
|
|
|
|
|
'data-cy',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
// We won't use an id selector if the id is invalid (starts with a number)
|
|
|
|
|
|
let idSelector = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
idSelector =
|
|
|
|
|
|
isAttributesDefined(element, ['id']) &&
|
|
|
|
|
|
!isCharacterNumber(element.id?.[0])
|
|
|
|
|
|
? // Certain apps don't have unique ids (ex. youtube)
|
|
|
|
|
|
finder(element, {
|
|
|
|
|
|
attr: (name) => name === 'id',
|
|
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: idSelector,
|
|
|
|
|
|
generalSelector,
|
|
|
|
|
|
attrSelector,
|
|
|
|
|
|
testIdSelector,
|
|
|
|
|
|
text: element.innerText,
|
|
|
|
|
|
href,
|
|
|
|
|
|
// Only try to pick an href selector if there is an href on the element
|
|
|
|
|
|
hrefSelector,
|
|
|
|
|
|
accessibilitySelector,
|
|
|
|
|
|
formSelector,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function genAttributeSet(element: HTMLElement, attributes: string[]) {
|
|
|
|
|
|
return new Set(
|
|
|
|
|
|
attributes.filter((attr) => {
|
|
|
|
|
|
const attrValue = element.getAttribute(attr);
|
|
|
|
|
|
return attrValue != null && attrValue.length > 0;
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isAttributesDefined(element: HTMLElement, attributes: string[]) {
|
|
|
|
|
|
return genAttributeSet(element, attributes).size > 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Gets all attributes that aren't null and empty
|
|
|
|
|
|
function genValidAttributeFilter(element: HTMLElement, attributes: string[]) {
|
|
|
|
|
|
const attrSet = genAttributeSet(element, attributes);
|
|
|
|
|
|
|
|
|
|
|
|
return (name: string) => attrSet.has(name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function genSelectorForAttributes(element: HTMLElement, attributes: string[]) {
|
|
|
|
|
|
let selector = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
selector = isAttributesDefined(element, attributes)
|
|
|
|
|
|
? finder(element, {
|
|
|
|
|
|
idName: () => false, // Don't use the id to generate a selector
|
|
|
|
|
|
attr: genValidAttributeFilter(element, attributes),
|
|
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
} catch (e) { }
|
|
|
|
|
|
|
|
|
|
|
|
return selector;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// isCharacterNumber
|
|
|
|
|
|
function isCharacterNumber(char: string) {
|
|
|
|
|
|
return char.length === 1 && char.match(/[0-9]/);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hoveredElement = document.elementFromPoint(x, y) as HTMLElement;
|
|
|
|
|
|
if (
|
|
|
|
|
|
hoveredElement != null &&
|
|
|
|
|
|
!hoveredElement.closest('#overlay-controls') != null
|
|
|
|
|
|
) {
|
|
|
|
|
|
const { parentElement } = hoveredElement;
|
|
|
|
|
|
// Match the logic in recorder.ts for link clicks
|
|
|
|
|
|
const element = parentElement?.tagName === 'A' ? parentElement : hoveredElement;
|
|
|
|
|
|
const generatedSelectors = genSelectors(element);
|
|
|
|
|
|
return generatedSelectors;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, { x: coordinates.x, y: coordinates.y });
|
|
|
|
|
|
return selectors;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
const { message, stack } = e as Error;
|
|
|
|
|
|
logger.log('error', `Error while retrieving element: ${message}`);
|
|
|
|
|
|
logger.log('error', `Stack: ${stack}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
2024-06-05 10:42:00 +05:30
|
|
|
|
};
|
|
|
|
|
|
|
2024-09-03 03:31:33 +05:30
|
|
|
|
function generateNonUniqueSelector(element: HTMLElement): string {
|
|
|
|
|
|
let selector = element.tagName.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
if (element.className) {
|
|
|
|
|
|
const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
|
|
|
|
|
if (classes.length > 0) {
|
|
|
|
|
|
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
|
|
|
|
|
if (validClasses.length > 0) {
|
|
|
|
|
|
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return selector;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2024-08-06 23:12:31 +05:30
|
|
|
|
|
2024-09-02 22:37:25 +05:30
|
|
|
|
interface SelectorResult {
|
|
|
|
|
|
generalSelector: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-08-06 23:12:31 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Returns the best non-unique css {@link Selectors} for the element on the page.
|
|
|
|
|
|
* @param page The page instance.
|
|
|
|
|
|
* @param coordinates Coordinates of an element.
|
|
|
|
|
|
* @category WorkflowManagement-Selectors
|
|
|
|
|
|
* @returns {Promise<Selectors|null|undefined>}
|
|
|
|
|
|
*/
|
2024-08-07 00:28:45 +05:30
|
|
|
|
|
2024-09-02 22:37:39 +05:30
|
|
|
|
export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates): Promise<SelectorResult> => {
|
2024-08-06 23:12:31 +05:30
|
|
|
|
try {
|
2024-08-12 18:24:27 +05:30
|
|
|
|
const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => {
|
|
|
|
|
|
|
2024-09-03 03:34:17 +05:30
|
|
|
|
// function getNonUniqueSelector(element: HTMLElement): string {
|
|
|
|
|
|
// let selector = element.tagName.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
// // Avoid using IDs to maintain non-uniqueness
|
|
|
|
|
|
// if (element.className) {
|
|
|
|
|
|
// const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
|
|
|
|
|
// if (classes.length > 0) {
|
|
|
|
|
|
// // Exclude utility classes and escape special characters
|
|
|
|
|
|
// const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
|
|
|
|
|
// if (validClasses.length > 0) {
|
|
|
|
|
|
// selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
|
|
|
|
|
// }
|
|
|
|
|
|
// }
|
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
|
|
// return selector;
|
|
|
|
|
|
// }
|
2024-08-06 23:12:31 +05:30
|
|
|
|
|
2024-08-12 18:24:27 +05:30
|
|
|
|
function getSelectorPath(element: HTMLElement | null): string {
|
|
|
|
|
|
const path: string[] = [];
|
2024-09-02 10:31:27 +05:30
|
|
|
|
let depth = 0;
|
|
|
|
|
|
const maxDepth = 2; // Limiting the depth of the selector path
|
|
|
|
|
|
|
|
|
|
|
|
while (element && element !== document.body && depth < maxDepth) {
|
2024-09-03 03:34:17 +05:30
|
|
|
|
const selector = generateNonUniqueSelector(element);
|
2024-09-02 20:40:17 +05:30
|
|
|
|
|
2024-08-07 17:55:36 +05:30
|
|
|
|
path.unshift(selector);
|
|
|
|
|
|
element = element.parentElement;
|
2024-09-02 10:31:27 +05:30
|
|
|
|
depth++;
|
2024-08-07 00:28:45 +05:30
|
|
|
|
}
|
2024-08-06 23:12:31 +05:30
|
|
|
|
|
2024-09-02 20:40:17 +05:30
|
|
|
|
return path.join(' > ');
|
2024-09-02 11:59:34 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
2024-08-12 18:24:27 +05:30
|
|
|
|
const element = document.elementFromPoint(x, y) as HTMLElement | null;
|
2024-08-07 00:28:45 +05:30
|
|
|
|
if (!element) return null;
|
2024-08-06 23:12:31 +05:30
|
|
|
|
|
2024-08-07 00:28:45 +05:30
|
|
|
|
const generalSelector = getSelectorPath(element);
|
2024-08-07 05:31:37 +05:30
|
|
|
|
return {
|
|
|
|
|
|
generalSelector,
|
|
|
|
|
|
};
|
2024-08-07 00:28:45 +05:30
|
|
|
|
}, coordinates);
|
2024-08-06 23:12:31 +05:30
|
|
|
|
|
2024-09-02 22:40:31 +05:30
|
|
|
|
return selectors || { generalSelector: '' };
|
2024-08-07 00:28:45 +05:30
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error in getNonUniqueSelectors:', error);
|
2024-09-02 22:37:57 +05:30
|
|
|
|
return { generalSelector: '' };
|
|
|
|
|
|
|
2024-08-06 23:12:31 +05:30
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2024-09-03 03:30:53 +05:30
|
|
|
|
export const getChildSelectors = async (page: Page, parentSelector: string, maxDepth: number = 2): Promise<string[]> => {
|
2024-09-02 20:46:30 +05:30
|
|
|
|
try {
|
2024-09-03 03:30:53 +05:30
|
|
|
|
const childSelectors = await page.evaluate(({ parentSelector, maxDepth }: { parentSelector: string, maxDepth: number }) => {
|
|
|
|
|
|
function getSelectors(element: HTMLElement, currentDepth: number): string[] {
|
|
|
|
|
|
if (currentDepth > maxDepth) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const selectors: string[] = [];
|
|
|
|
|
|
const childElements = Array.from(element.children);
|
|
|
|
|
|
|
|
|
|
|
|
for (const child of childElements) {
|
|
|
|
|
|
let selector = child.tagName.toLowerCase();
|
|
|
|
|
|
if (child.className) {
|
|
|
|
|
|
const classes = child.className.split(/\s+/).filter((cls: string) => Boolean(cls));
|
|
|
|
|
|
if (classes.length > 0) {
|
|
|
|
|
|
const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':'));
|
|
|
|
|
|
if (validClasses.length > 0) {
|
|
|
|
|
|
selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.');
|
|
|
|
|
|
}
|
2024-09-02 20:46:30 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-09-03 03:30:53 +05:30
|
|
|
|
selectors.push(selector);
|
|
|
|
|
|
|
|
|
|
|
|
// Recursively get selectors for deeper levels
|
|
|
|
|
|
selectors.push(...getSelectors(child as HTMLElement, currentDepth + 1));
|
2024-09-02 20:46:30 +05:30
|
|
|
|
}
|
2024-09-03 03:30:53 +05:30
|
|
|
|
|
|
|
|
|
|
return selectors;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parentElement = document.querySelector(parentSelector) as HTMLElement;
|
|
|
|
|
|
if (!parentElement) return [];
|
|
|
|
|
|
|
|
|
|
|
|
return getSelectors(parentElement, 0);
|
|
|
|
|
|
}, { parentSelector, maxDepth });
|
2024-09-02 20:46:30 +05:30
|
|
|
|
|
|
|
|
|
|
return childSelectors || [];
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error in getChildSelectors:', error);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2024-09-03 03:30:53 +05:30
|
|
|
|
|
2024-06-06 05:13:19 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Returns the first pair from the given workflow that contains the given selector
|
|
|
|
|
|
* inside the where condition, and it is the only selector there.
|
|
|
|
|
|
* If a match is not found, returns undefined.
|
|
|
|
|
|
* @param selector The selector to find.
|
|
|
|
|
|
* @param workflow The workflow to search in.
|
|
|
|
|
|
* @category WorkflowManagement
|
|
|
|
|
|
* @returns {Promise<WhereWhatPair|undefined>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const selectorAlreadyInWorkflow = (selector: string, workflow: Workflow) => {
|
|
|
|
|
|
return workflow.find((pair: WhereWhatPair) => {
|
|
|
|
|
|
if (pair.where.selectors?.includes(selector)) {
|
|
|
|
|
|
if (pair.where.selectors?.length === 1) {
|
|
|
|
|
|
return pair;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2024-06-05 06:26:13 +05:30
|
|
|
|
|
2024-06-06 05:13:48 +05:30
|
|
|
|
/**
|
|
|
|
|
|
* Checks whether the given selectors are visible on the page at the same time.
|
|
|
|
|
|
* @param selectors The selectors to check.
|
|
|
|
|
|
* @param page The page to use for the validation.
|
|
|
|
|
|
* @category WorkflowManagement
|
|
|
|
|
|
*/
|
2024-06-06 05:14:18 +05:30
|
|
|
|
export const isRuleOvershadowing = async (selectors: string[], page: Page): Promise<boolean> => {
|
|
|
|
|
|
for (const selector of selectors) {
|
2024-06-06 05:13:48 +05:30
|
|
|
|
const areElsVisible = await page.$$eval(selector,
|
|
|
|
|
|
(elems) => {
|
2024-06-06 05:14:18 +05:30
|
|
|
|
const isVisible = (elem: HTMLElement | SVGElement) => {
|
2024-06-06 05:13:48 +05:30
|
|
|
|
if (elem instanceof HTMLElement) {
|
|
|
|
|
|
return !!(elem.offsetWidth
|
|
|
|
|
|
|| elem.offsetHeight
|
|
|
|
|
|
|| elem.getClientRects().length
|
|
|
|
|
|
&& window.getComputedStyle(elem).visibility !== "hidden");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return !!(elem.getClientRects().length
|
|
|
|
|
|
&& window.getComputedStyle(elem).visibility !== "hidden");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const visibility: boolean[] = [];
|
|
|
|
|
|
elems.forEach((el) => visibility.push(isVisible(el)))
|
|
|
|
|
|
return visibility;
|
|
|
|
|
|
})
|
|
|
|
|
|
if (areElsVisible.length === 0) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-06-06 05:14:18 +05:30
|
|
|
|
if (areElsVisible.includes(false)) {
|
2024-06-06 05:13:48 +05:30
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
2024-06-06 05:14:33 +05:30
|
|
|
|
}
|