Commit 856e676d authored by Yiming Zhou's avatar Yiming Zhou Committed by Commit Bot

Introducing improvements for the Action Recorder Extension.

1. Improved the xpath generator. The generator will produce even more concise xpaths. This improvement helps to make test recipes readable to humans.
2. Changed the way the extension queries for an iframe's context. Prior to this change, the extension would construct a path from the iframe to the top level frame by making a query at each ancestor frame node. However, InProcessBrowserTest does not need a path to go from the top level frame to a descendant iframe. Therefore, in this change the extension only makes one query at the parent frame node.
3. Moved the frame context query action to the front of the start recording workflow. This move cuts one message roundtrip from the extension's background script to the extension's content script.
4. Made the extension jot down a target element's visibility state when recording an action. Prior to this change, the extension assumes that every target element is visible, enabled and on the top of the page. However, the extension's complex action recording logic sometimes catches actions on invisible or partially obscured elements.
5. Added refined logic to the extension to distinguish between a user typing action and a Chrome autofill action. Prior to this change the extension simply assumes that Chrome always autofills every autofill-able field. If a user types inside an autofillable field, the extension will mistakenly record an autofill action. With this change the extension uses keyboard events to detect when a user types inside a field, eliminating the false positive.
6. Began implementing new features to capture Chrome Password Manager actions.

Bug: 855284
Change-Id: Ic7ff3af95cdc9f308c3ad061a3506fced150b4f8
Reviewed-on: https://chromium-review.googlesource.com/1132540
Commit-Queue: Yiming Zhou <uwyiming@google.com>
Reviewed-by: default avatarJared Saul <jsaul@google.com>
Reviewed-by: default avatarMathieu Perreault <mathp@chromium.org>
Cr-Commit-Position: refs/heads/master@{#574385}
parent c18d1990
...@@ -288,26 +288,80 @@ ...@@ -288,26 +288,80 @@
}); });
} }
function getIframeContext(tabId, frameId, iframeLocation) { function getIframeContext(tabId, frameId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (frameId === 0) { if (frameId === 0) {
resolve({ isIframe: false }); resolve({ isIframe: false });
} else { } else {
chrome.webNavigation.getFrame( let context = { isIframe: true };
{ tabId: tabId, frameId: frameId }, (details) => { getAllFramesInTab(tabId)
if (chrome.runtime.lastError) { .then((details) => {
reject(`Unable to query for frame info on frame ${frameId}`); let targetFrame;
} else { for (let index = 0; index < details.length; index++) {
sendMessageToTab(tabId, { if (details[index].frameId === frameId) {
type: RecorderMsgEnum.GET_IFRAME_XPATH, targetFrame = details[index];
frameId: frameId, break;
location: iframeLocation }
}, { frameId: details.parentFrameId }).then((context) => {
resolve(context);
}).catch((message) => {
reject(message);
});
} }
// Send a message to the parent frame and see if the iframe has a
// 'name' attribute.
sendMessageToTab(tabId, {
type: RecorderMsgEnum.GET_IFRAME_NAME,
url: targetFrame.url
}, {
frameId: targetFrame.parentFrameId
})
.then((frameName) => {
if (frameName !== '') {
context.browserTest = { name: frameName };
resolve(context);
} else {
const targetFrameUrl = new URL(targetFrame.url);
// The frame does not have a 'name' attribute. Check if the
// frame has a unique combination of scheme, host and port.
//
// The Captured Site automation framework can identify an
// iframe by its scheme + host + port, provided this
// information combination is unique. Identifying an iframe
// through its scheme + host + port is more preferable than
// identifying an iframe through its URL. An URL will
// frequently contain parameters, and many websites use random
// number generator or date generator to create these
// parameters. For example, in the following URL
//
// https://payment.bhphotovideo.com/static/desktop/v2.0/
// index.html
// #paypageId=aLGNuLSTJVwgEiCn&cartID=333334444
// &receiverID=77777777-7777-4777-b777-777777888888
// &uuid=77777777-7777-4777-b777-778888888888
//
// The site created the parameters cartID, receiverID and uuid
// using random number generators. These parameters will have
// different values every time the browser loads the page.
// Therefore automation will not be able to identify an iframe
// that loads this URL.
let frameHostAndSchemeIsUnique = true;
for (let index = 0; index < details.length; index++) {
const url = new URL(details[index].url);
if (details[index].frameId !== targetFrame.frameId &&
targetFrameUrl.protocol === url.protocol &&
targetFrameUrl.host === url.host) {
frameHostAndSchemeIsUnique = false;
break;
}
}
if (frameHostAndSchemeIsUnique) {
context.browserTest = {
schemeAndHost:
`${targetFrameUrl.protocol}//${targetFrameUrl.host}`
};
resolve(context);
} else {
context.browserTest = { url: targetFrame.url };
resolve(context);
}
}
});
}); });
} }
}); });
...@@ -354,8 +408,14 @@ ...@@ -354,8 +408,14 @@
function startRecordingOnTabAndFrame(tabId, frameId) { function startRecordingOnTabAndFrame(tabId, frameId) {
const ret = const ret =
sendMessageToTab(tabId, { type: RecorderMsgEnum.START }, getIframeContext(tabId, frameId)
{ frameId: frameId }) .then((context) => {
return sendMessageToTab(tabId,
{ type: RecorderMsgEnum.START,
frameContext: context
},
{ frameId: frameId });
})
.then((response) => { .then((response) => {
if (!response) { if (!response) {
return Promise.reject( return Promise.reject(
...@@ -546,9 +606,9 @@ ...@@ -546,9 +606,9 @@
.then((details) => { .then((details) => {
let recordingStartedOnRootFramePromise; let recordingStartedOnRootFramePromise;
details.forEach((frame) => { details.forEach((frame) => {
// Th extension has no need and no permission to inject script // The extension has no need and no permission to inject script
// into a blank page. // into 'about:' pages, such as the 'about:blank' page.
if (frame.url !== 'about:blank') { if (!frame.url.startsWith('about:')) {
const promise = const promise =
startRecordingOnTabAndFrame(tab.id, frame.frameId); startRecordingOnTabAndFrame(tab.id, frame.frameId);
if (frame.frameId === 0) { if (frame.frameId === 0) {
...@@ -596,10 +656,10 @@ ...@@ -596,10 +656,10 @@
chrome.webNavigation.onCompleted.addListener((details) => { chrome.webNavigation.onCompleted.addListener((details) => {
getRecordingTabId().then((tabId) => { getRecordingTabId().then((tabId) => {
if (details.tabId === tabId && if (details.tabId === tabId &&
// Skip recording on about:blank. No meaningful user interaction will // Skip recording on 'about:' pages. No meaningful user interaction
// occur on a blank page. Plus, this extension has no permission to // occur on 'about:'' pages such as the blank page. Plus, this
// access about:blank. // extension has no permission to access 'about:' pages.
details.url !== 'about:blank') { !details.url.startsWith('about:')) {
startRecordingOnTabAndFrame(tabId, details.frameId) startRecordingOnTabAndFrame(tabId, details.frameId)
.then(() => getRecordingState()) .then(() => getRecordingState())
.then((state) => { .then((state) => {
......
...@@ -31,9 +31,9 @@ const RecorderMsgEnum = { ...@@ -31,9 +31,9 @@ const RecorderMsgEnum = {
START: 'start-recording', START: 'start-recording',
STOP: 'stop-recording', STOP: 'stop-recording',
CANCEL: 'cancel-recording', CANCEL: 'cancel-recording',
GET_FRAME_CONTEXT: 'get-frame-context', GET_IFRAME_NAME: 'get-iframe-name',
GET_IFRAME_XPATH: 'get-iframe-xpath',
ADD_ACTION: 'record-action', ADD_ACTION: 'record-action',
MEMORIZE_PASSWORD_FORM: 'memorize-password-form',
}; };
const Local_Storage_Vars = { const Local_Storage_Vars = {
...@@ -49,4 +49,4 @@ const Indexed_DB_Vars = { ...@@ -49,4 +49,4 @@ const Indexed_DB_Vars = {
ATTRIBUTES: 'Attributes', ATTRIBUTES: 'Attributes',
NAME: 'name', NAME: 'name',
URL: 'url' URL: 'url'
}; };
\ No newline at end of file
...@@ -33,12 +33,18 @@ ...@@ -33,12 +33,18 @@
// local name results in a unique XPath. // local name results in a unique XPath.
let nodeXPath = buildXPathForSingleNode(node); let nodeXPath = buildXPathForSingleNode(node);
let testXPath = `//${nodeXPath}${xPath}`; let testXPath = `//${nodeXPath}${xPath}`;
if (countNumOfMatches(testXPath) === 1) { let numMatches = countNumberOfMatches(testXPath);
if (numMatches === 1) {
return { isUnique: true, xPath: testXPath }; return { isUnique: true, xPath: testXPath };
} }
// Build a list of potential classifiers using
// * The element's explicit attributes.
// * The element's text content, if the element contains text.
let classifiers = []; let classifiers = [];
// Try adding each explicit attribute of the element to the XPath. let attributes = [];
let attrRawValues = [];
// Populate the attribute list with the element's explicit attributes.
for (let index = 0; index < node.attributes.length; index++) { for (let index = 0; index < node.attributes.length; index++) {
const attr = node.attributes[index].name; const attr = node.attributes[index].name;
if (attr === 'style') { if (attr === 'style') {
...@@ -55,50 +61,47 @@ ...@@ -55,50 +61,47 @@
// attribute. // attribute.
continue; continue;
} else { } else {
let classifier = buildClassifier(node, `@${attr}`, attributes.push(`@${attr}`);
node.attributes[index].value); attrRawValues.push(node.attributes[index].value);
// Add the classifier and see if it generates a unique XPath.
classifiers.push(classifier);
nodeXPath = buildXPathForSingleNode(node, classifiers);
let testXPath = `//${nodeXPath}${xPath}`;
let numMatches = countNumOfMatches(testXPath);
if (numMatches === 0) {
// The classifier is faulty, log an error and remove the
// classifier.
console.warn('Encountered faulty classifier: ' +
classifiers.pop());
} else if (numMatches === 1) {
// The current XPath is unique, exit.
return { isUnique: true, xPath: testXPath };
}
} }
} }
// Add the element's text content to the attribute list.
// For HTML elements that contains text content, try to construct
// an XPath using the text content.
switch (node.localName) { switch (node.localName) {
case 'a': case 'a':
case 'span': case 'span':
case 'button': case 'button':
let classifier = buildClassifier(node, 'text()', attributes.push('text()');
node.textContent); attrRawValues.push(node.textContent);
classifiers.push(classifier);
nodeXPath = buildXPathForSingleNode(node, classifiers);
let testXPath = `//${nodeXPath}${xPath}`;
let numMatches = countNumOfMatches(testXPath);
if (numMatches === 0) {
// The classifier is faulty, log an error and remove the
// classifier.
console.warn('Encountered faulty classifier: ' +
classifiers.pop());
} else if (numMatches === 1) {
// The current XPath is unique, exit.
return { isUnique: true, xPath: testXPath };
}
break; break;
default: default:
} }
// Iterate through each attribute.
for (let index = 0; index < attributes.length; index++) {
let classifier = buildClassifier(node, attributes[index],
attrRawValues[index]);
// Add the classifier and see if adding it generates a unique XPath.
let numMatchesBefore = numMatches;
classifiers.push(classifier);
nodeXPath = buildXPathForSingleNode(node, classifiers);
testXPath = `//${nodeXPath}${xPath}`;
numMatches = countNumberOfMatches(testXPath);
if (numMatches === 0) {
// The classifier is faulty, log an error and remove the classifier.
console.warn('Encountered faulty classifier: ' +
classifiers.pop());
} else if (numMatches === 1) {
// The current XPath is unique, exit.
return { isUnique: true, xPath: testXPath };
} else if (numMatches === numMatchesBefore) {
// Adding this classifier to the XPath does not narrow down the
// number of elements this XPath matches. Therefore the XPath does not
// need this classifier. Remove the classifier.
classifiers.pop();
}
}
// A XPath with the current node as the root is not unique. // A XPath with the current node as the root is not unique.
// Check if the node has siblings with the same XPath. If so, // Check if the node has siblings with the same XPath. If so,
// add a child node index to the current node's XPath. // add a child node index to the current node's XPath.
...@@ -162,7 +165,7 @@ ...@@ -162,7 +165,7 @@
return xPathSelector; return xPathSelector;
} }
function countNumOfMatches(xPath) { function countNumberOfMatches(xPath) {
const queryResult = document.evaluate( const queryResult = document.evaluate(
`count(${xPath})`, document, null, XPathResult.NUMBER_TYPE, null); `count(${xPath})`, document, null, XPathResult.NUMBER_TYPE, null);
return queryResult.numberValue; return queryResult.numberValue;
...@@ -172,6 +175,7 @@ ...@@ -172,6 +175,7 @@
}; };
let autofillTriggerElementSelector = null; let autofillTriggerElementSelector = null;
let lastTypingEventTargetValue = null;
let frameContext; let frameContext;
let mutationObserver = null; let mutationObserver = null;
let started = false; let started = false;
...@@ -189,6 +193,10 @@ ...@@ -189,6 +193,10 @@
canTriggerAutofill(element); canTriggerAutofill(element);
} }
function isPasswordInputElement(element) {
return element.getAttribute('type') === 'password';
}
function canTriggerAutofill(element) { function canTriggerAutofill(element) {
return (element.localName === 'input' && return (element.localName === 'input' &&
['checkbox', 'radio', 'button', 'submit', 'hidden', 'reset'] ['checkbox', 'radio', 'button', 'submit', 'hidden', 'reset']
...@@ -219,46 +227,87 @@ ...@@ -219,46 +227,87 @@
}); });
} }
function memorizePasswordFormInputs(passwordField) {
// Extract the 'form signature' value from the password's 'title'
// attribute.
const title = passwordField.getAttribute('title');
if (!title)
return;
let formSign = null;
const attrs = title.split('\n');
for (let index = 0; index < attrs.length; index++) {
if (attrs[index].startsWith('form signature')) {
formSign = attrs[index];
}
}
if (formSign)
return;
// Identify the 'user name' field and grab the field value.
const fields = document.querySelectorAll(`*[title*='${formSign}']`);
for (let index = 0; index < fields.length; index++) {
const field = fields[index];
const type = field.getAttribute('autofill-prediction');
if (type === 'HTML_TYPE_EMAIL') {
// (TODO: uwyiming@) Send the password to the UI content script. A
// user may then add password manager events through the Recorder
// Extension UI, without having to retype the password.
//sendRuntimeMessageToBackgroundScript({
// type: RecorderMsgEnum.MEMORIZE_PASSWORD_FORM,
// password: passwordField.value,
// user:
//});
}
}
}
function onInputChangeActionHandler(event) { function onInputChangeActionHandler(event) {
const selector = buildXPathForElement(event.target); const selector = buildXPathForElement(event.target);
if (isAutofillableElement(event.target)) { const elementReadyState = automation_helper.getElementState(event.target);
const autofillPrediction = const autofillPrediction =
event.target.getAttribute('autofill-prediction'); event.target.getAttribute('autofill-prediction');
// If a field that can trigger autofill has previously been let action = {
// clicked, fire a trigger autofill event. selector: selector,
context: frameContext,
visibility: elementReadyState
};
if (event.target.localName === 'select') {
if (document.activeElement === event.target) {
const index = event.target.options.selectedIndex;
console.log(`Select detected on: ${selector} with '${index}'`);
action.type = 'select';
action.index = index;
addActionToRecipe(action);
} else {
action.type = 'validateField';
action.expectedValue = event.target.value;
if (autofillPrediction) {
action.expectedAutofillType = autofillPrediction;
}
addActionToRecipe(action);
}
} else if (lastTypingEventTargetValue === event.target.value) {
console.log(`Typing detected on: ${selector}`);
action.type = 'type';
action.value = event.target.value;
addActionToRecipe(action);
} else {
// If the user has previously clicked on a field that can trigger
// autofill, add a trigger autofill action.
if (autofillTriggerElementSelector !== null) { if (autofillTriggerElementSelector !== null) {
console.log(`Triggered autofill on ${autofillTriggerElementSelector}`); console.log(`Triggered autofill on ${autofillTriggerElementSelector}`);
addActionToRecipe({ action.type = 'autofill';
selector: selector, addActionToRecipe(action);
context: frameContext,
type: 'autofill'
});
autofillTriggerElementSelector = null; autofillTriggerElementSelector = null;
} }
addActionToRecipe({ action.type = 'validateField';
selector: selector, action.expectedValue = event.target.value;
context: frameContext, if (autofillPrediction) {
expectedAutofillType: autofillPrediction, action.expectedAutofillType = autofillPrediction;
expectedValue: event.target.value, }
type: 'validateField' addActionToRecipe(action);
});
} else if (event.target.localName === 'select') {
const index = event.target.options.selectedIndex;
console.log(`Select detected on: ${selector} with '${index}'`);
addActionToRecipe({
selector: selector,
context: frameContext,
index: index,
type: 'select'
});
} else {
console.log(`Typing detected on: ${selector}`);
addActionToRecipe({
selector: selector,
context: frameContext,
value: event.target.value,
type: 'type'
});
} }
} }
...@@ -287,6 +336,8 @@ ...@@ -287,6 +336,8 @@
// clicks on a scroll bar. // clicks on a scroll bar.
event.target.localName !== 'html') { event.target.localName !== 'html') {
const element = event.target; const element = event.target;
const elementReadyState =
automation_helper.getElementState(event.target);
const selector = buildXPathForElement(element); const selector = buildXPathForElement(element);
console.log(`Left-click detected on: ${selector}`); console.log(`Left-click detected on: ${selector}`);
...@@ -300,6 +351,7 @@ ...@@ -300,6 +351,7 @@
} else { } else {
addActionToRecipe({ addActionToRecipe({
selector: selector, selector: selector,
visibility: elementReadyState,
context: frameContext, context: frameContext,
type: 'click' type: 'click'
}); });
...@@ -308,15 +360,26 @@ ...@@ -308,15 +360,26 @@
} else if (event.button === Buttons.RIGHT_BUTTON) { } else if (event.button === Buttons.RIGHT_BUTTON) {
const element = event.target; const element = event.target;
const selector = buildXPathForElement(element); const selector = buildXPathForElement(element);
const elementReadyState =
automation_helper.getElementState(event.target);
console.log(`Right-click detected on: ${selector}`); console.log(`Right-click detected on: ${selector}`);
addActionToRecipe({ addActionToRecipe({
selector: selector, selector: selector,
visibility: elementReadyState,
context: frameContext, context: frameContext,
type: 'hover' type: 'hover'
}); });
} }
} }
function onKeyUpActionHandler(event) {
if (isEditableInputElement(event.target)) {
lastTypingEventTargetValue = event.target.value;
} else {
lastTypingEventTargetValue = null;
}
}
function startRecording() { function startRecording() {
const promise = const promise =
// First, obtain the current frame's context. // First, obtain the current frame's context.
...@@ -327,7 +390,7 @@ ...@@ -327,7 +390,7 @@
frameContext = context; frameContext = context;
// Register on change listeners on all the input elements. // Register on change listeners on all the input elements.
registerOnInputChangeActionListener(document); registerOnInputChangeActionListener(document);
// Register a mouse up listener on the entire dom. // Register a mouse up listener on the entire document.
// //
// The content script registers a 'Mouse Up' listener rather than a // The content script registers a 'Mouse Up' listener rather than a
// 'Mouse Down' to correctly handle the following scenario: // 'Mouse Down' to correctly handle the following scenario:
...@@ -342,6 +405,8 @@ ...@@ -342,6 +405,8 @@
// To capture the correct sequence of actions, the content script // To capture the correct sequence of actions, the content script
// should tie left mouse click actions to the mouseup event. // should tie left mouse click actions to the mouseup event.
document.addEventListener('mouseup', onClickActionHander); document.addEventListener('mouseup', onClickActionHander);
// Register a key press listener on the entire document.
document.addEventListener('keyup', onKeyUpActionHandler);
// Setup mutation observer to listen for event on nodes added after // Setup mutation observer to listen for event on nodes added after
// recording starts. // recording starts.
mutationObserver = new MutationObserver((mutations) => { mutationObserver = new MutationObserver((mutations) => {
...@@ -367,19 +432,15 @@ ...@@ -367,19 +432,15 @@
if (started) { if (started) {
mutationObserver.disconnect(); mutationObserver.disconnect();
document.removeEventListener('mousedown', onClickActionHander); document.removeEventListener('mousedown', onClickActionHander);
document.removeEventListener('keyup', onKeyUpActionHandler);
deRegisterOnInputChangeActionListener(document); deRegisterOnInputChangeActionListener(document);
} }
} }
function queryIframeContext(frameId, frameLocation) { function queryIframeName(iframeUrl) {
// Check if we cached the XPath for this iframe.
if (iframeContextMap[frameId]) {
return Promise.resolve(iframeContextMap[frameId]);
}
let iframe = null; let iframe = null;
const frameLocation = new URL(iframeUrl);
const iframes = document.querySelectorAll('iframe'); const iframes = document.querySelectorAll('iframe');
let numIframeWithSameSchemeAndHost = 0;
// Find the target iframe. // Find the target iframe.
for (let index = 0; index < iframes.length; index++) { for (let index = 0; index < iframes.length; index++) {
const url = new URL(iframes[index].src, const url = new URL(iframes[index].src,
...@@ -402,51 +463,18 @@ ...@@ -402,51 +463,18 @@
frameLocation.search === url.search) { frameLocation.search === url.search) {
iframe = iframes[index]; iframe = iframes[index];
} }
// Count the number of iframes with the same protocol and the same host.
if (frameLocation.protocol === url.protocol &&
frameLocation.host === url.host) {
numIframeWithSameSchemeAndHost++;
}
} }
if (iframe === null) { if (iframe === null) {
return Promise.reject( return Promise.reject(
new Error('Unable to find iframe with url ' + new Error(`Unable to find iframe with url '${iframeUrl}'!`));
`'${frameLocation.href}', for frame ${frameId}`));
} }
let context = { isIframe: true, browser_test: {} };
if (iframe.name) { if (iframe.name) {
context.browser_test.name = iframe.name; return Promise.resolve(iframe.name);
}
else if (numIframeWithSameSchemeAndHost === 1) {
// Register the iframe's scheme, host and port.
// The Captured Site automation framework can identify an iframe by its
// scheme + host + port, provided this information combination is unique.
// Identifying an iframe through its scheme + host + port is preferable
// than identityfing an iframe through its URL. An URL will frequently
// contain parameters, and many websites use random number generator
// or date generator to create these parameters. For example, in the
// following URL
//
// https://payment.bhphotovideo.com/static/desktop/v2.0/index.html
// #paypageId=aLGNuLSTJVwgEiCn&cartID=333334444
// &receiverID=77777777-7777-4777-b777-777777888888
// &uuid=77777777-7777-4777-b777-778888888888
//
// The site created the parameters cartID, receiverID and uuid using
// random number generators. These parameters will have different values
// every time the browser loads the page. Therefore automation will not
// be able to identify an iframe that loads this URL.
context.browser_test.schemeAndHost =
`${frameLocation.protocol}//${frameLocation.host}`;
} else { } else {
context.browser_test.url = frameLocation.href; return Promise.resolve('');
} }
iframeContextMap[frameId] = context;
return Promise.resolve(context);
} }
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
...@@ -466,8 +494,8 @@ ...@@ -466,8 +494,8 @@
stopRecording(); stopRecording();
sendResponse(true); sendResponse(true);
break; break;
case RecorderMsgEnum.GET_IFRAME_XPATH: case RecorderMsgEnum.GET_IFRAME_NAME:
queryIframeContext(request.frameId, request.location) queryIframeName(request.url)
.then((context) => { .then((context) => {
sendResponse(context); sendResponse(context);
}).catch((error) => { }).catch((error) => {
...@@ -482,4 +510,57 @@ ...@@ -482,4 +510,57 @@
// to persist. // to persist.
return false; return false;
}); });
if (typeof automation_helper === 'undefined') {
var automation_helper = {
// An enum specifying the state of an element on the page.
DomElementReadyState:
Object.freeze({
"present": 0,
"visible": 1 << 0,
"enabled": 1 << 1,
"on_top": 1 << 2,
}),
};
automation_helper.getElementState = function(element) {
let state_flags = this.DomElementReadyState.present;
// Check if the element is disabled.
if (!element.disabled) {
state_flags |= this.DomElementReadyState.enabled;
}
// Check if the element is visible.
if (element.offsetParent !== null &&
element.offsetWidth > 0 &&
element.offsetHeight > 0) {
state_flags |= this.DomElementReadyState.visible;
// Check if the element is also on top.
const rect = element.getBoundingClientRect();
const topElement = document.elementFromPoint(
// As coordinates, use the center of the element, minus
// the window offset in case the element is outside the
// view.
rect.left + rect.width / 2 - window.pageXOffset,
rect.top + rect.height / 2 - window.pageYOffset);
if (isSelfOrDescendant(element, topElement)) {
state_flags |= this.DomElementReadyState.on_top;
}
}
return state_flags;
}
function isSelfOrDescendant(parent, child) {
var node = child;
while (node != null) {
if (node == parent) {
return true;
}
node = node.parentNode;
}
return false;
}
return automation_helper;
}
})(); })();
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment