2020-01-06 18:22:35 -08:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2019-11-21 14:43:30 -08:00
|
|
|
|
2020-02-25 07:06:20 -08:00
|
|
|
import * as types from '../types';
|
2019-12-16 20:49:18 -08:00
|
|
|
|
2020-03-25 14:08:46 -07:00
|
|
|
type Predicate = () => any;
|
2019-11-22 15:36:17 -08:00
|
|
|
|
2019-11-22 17:27:09 -08:00
|
|
|
class Injected {
|
2019-12-04 13:11:10 -08:00
|
|
|
isVisible(element: Element): boolean {
|
|
|
|
if (!element.ownerDocument || !element.ownerDocument.defaultView)
|
|
|
|
return true;
|
|
|
|
const style = element.ownerDocument.defaultView.getComputedStyle(element);
|
|
|
|
if (!style || style.visibility === 'hidden')
|
|
|
|
return false;
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
return !!(rect.top || rect.bottom || rect.width || rect.height);
|
|
|
|
}
|
|
|
|
|
2020-03-25 14:08:46 -07:00
|
|
|
private _pollMutation(predicate: Predicate, timeout: number): Promise<any> {
|
2019-12-03 10:51:41 -08:00
|
|
|
let timedOut = false;
|
|
|
|
if (timeout)
|
|
|
|
setTimeout(() => timedOut = true, timeout);
|
|
|
|
|
2020-03-25 14:08:46 -07:00
|
|
|
const success = predicate();
|
2019-12-03 10:51:41 -08:00
|
|
|
if (success)
|
|
|
|
return Promise.resolve(success);
|
|
|
|
|
2019-12-10 11:15:14 -08:00
|
|
|
let fulfill: (result?: any) => void;
|
2019-12-03 10:51:41 -08:00
|
|
|
const result = new Promise(x => fulfill = x);
|
2019-12-18 18:11:02 -08:00
|
|
|
const observer = new MutationObserver(() => {
|
2019-12-03 10:51:41 -08:00
|
|
|
if (timedOut) {
|
|
|
|
observer.disconnect();
|
|
|
|
fulfill();
|
2019-12-18 18:11:02 -08:00
|
|
|
return;
|
2019-12-03 10:51:41 -08:00
|
|
|
}
|
2020-03-25 14:08:46 -07:00
|
|
|
const success = predicate();
|
2019-12-03 10:51:41 -08:00
|
|
|
if (success) {
|
|
|
|
observer.disconnect();
|
|
|
|
fulfill(success);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
observer.observe(document, {
|
|
|
|
childList: true,
|
|
|
|
subtree: true,
|
|
|
|
attributes: true
|
|
|
|
});
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2020-03-25 14:08:46 -07:00
|
|
|
private _pollRaf(predicate: Predicate, timeout: number): Promise<any> {
|
2019-12-03 10:51:41 -08:00
|
|
|
let timedOut = false;
|
|
|
|
if (timeout)
|
|
|
|
setTimeout(() => timedOut = true, timeout);
|
|
|
|
|
2019-12-10 11:15:14 -08:00
|
|
|
let fulfill: (result?: any) => void;
|
2019-12-03 10:51:41 -08:00
|
|
|
const result = new Promise(x => fulfill = x);
|
|
|
|
|
2019-12-18 18:11:02 -08:00
|
|
|
const onRaf = () => {
|
2019-12-03 10:51:41 -08:00
|
|
|
if (timedOut) {
|
|
|
|
fulfill();
|
|
|
|
return;
|
|
|
|
}
|
2020-03-25 14:08:46 -07:00
|
|
|
const success = predicate();
|
2019-12-03 10:51:41 -08:00
|
|
|
if (success)
|
|
|
|
fulfill(success);
|
|
|
|
else
|
|
|
|
requestAnimationFrame(onRaf);
|
2019-12-18 18:11:02 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
onRaf();
|
|
|
|
return result;
|
2019-12-03 10:51:41 -08:00
|
|
|
}
|
|
|
|
|
2020-03-25 14:08:46 -07:00
|
|
|
private _pollInterval(pollInterval: number, predicate: Predicate, timeout: number): Promise<any> {
|
2019-12-03 10:51:41 -08:00
|
|
|
let timedOut = false;
|
|
|
|
if (timeout)
|
|
|
|
setTimeout(() => timedOut = true, timeout);
|
|
|
|
|
2019-12-10 11:15:14 -08:00
|
|
|
let fulfill: (result?: any) => void;
|
2019-12-03 10:51:41 -08:00
|
|
|
const result = new Promise(x => fulfill = x);
|
2019-12-18 18:11:02 -08:00
|
|
|
const onTimeout = () => {
|
2019-12-03 10:51:41 -08:00
|
|
|
if (timedOut) {
|
|
|
|
fulfill();
|
|
|
|
return;
|
|
|
|
}
|
2020-03-25 14:08:46 -07:00
|
|
|
const success = predicate();
|
2019-12-03 10:51:41 -08:00
|
|
|
if (success)
|
|
|
|
fulfill(success);
|
|
|
|
else
|
|
|
|
setTimeout(onTimeout, pollInterval);
|
2019-12-18 18:11:02 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
onTimeout();
|
|
|
|
return result;
|
2019-12-03 10:51:41 -08:00
|
|
|
}
|
2020-02-19 09:34:57 -08:00
|
|
|
|
2020-03-25 14:08:46 -07:00
|
|
|
poll(polling: 'raf' | 'mutation' | number, timeout: number, predicate: Predicate): Promise<any> {
|
2020-02-19 09:34:57 -08:00
|
|
|
if (polling === 'raf')
|
2020-03-25 14:08:46 -07:00
|
|
|
return this._pollRaf(predicate, timeout);
|
2020-02-19 09:34:57 -08:00
|
|
|
if (polling === 'mutation')
|
2020-03-25 14:08:46 -07:00
|
|
|
return this._pollMutation(predicate, timeout);
|
|
|
|
return this._pollInterval(polling, predicate, timeout);
|
2020-02-19 09:34:57 -08:00
|
|
|
}
|
2020-02-25 07:06:20 -08:00
|
|
|
|
|
|
|
getElementBorderWidth(node: Node): { left: number; top: number; } {
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE || !node.ownerDocument || !node.ownerDocument.defaultView)
|
|
|
|
return { left: 0, top: 0 };
|
|
|
|
const style = node.ownerDocument.defaultView.getComputedStyle(node as Element);
|
|
|
|
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
|
|
|
|
}
|
|
|
|
|
|
|
|
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]) {
|
|
|
|
if (node.nodeName.toLowerCase() !== 'select')
|
|
|
|
throw new Error('Element is not a <select> element.');
|
|
|
|
const element = node as HTMLSelectElement;
|
|
|
|
|
|
|
|
const options = Array.from(element.options);
|
|
|
|
element.value = undefined as any;
|
|
|
|
for (let index = 0; index < options.length; index++) {
|
|
|
|
const option = options[index];
|
|
|
|
option.selected = optionsToSelect.some(optionToSelect => {
|
|
|
|
if (optionToSelect instanceof Node)
|
|
|
|
return option === optionToSelect;
|
|
|
|
let matches = true;
|
|
|
|
if (optionToSelect.value !== undefined)
|
|
|
|
matches = matches && optionToSelect.value === option.value;
|
|
|
|
if (optionToSelect.label !== undefined)
|
|
|
|
matches = matches && optionToSelect.label === option.label;
|
|
|
|
if (optionToSelect.index !== undefined)
|
|
|
|
matches = matches && optionToSelect.index === index;
|
|
|
|
return matches;
|
|
|
|
});
|
|
|
|
if (option.selected && !element.multiple)
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
element.dispatchEvent(new Event('input', { 'bubbles': true }));
|
|
|
|
element.dispatchEvent(new Event('change', { 'bubbles': true }));
|
|
|
|
return options.filter(option => option.selected).map(option => option.value);
|
|
|
|
}
|
|
|
|
|
|
|
|
fill(node: Node, value: string) {
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
return 'Node is not of type HTMLElement';
|
|
|
|
const element = node as HTMLElement;
|
2020-03-25 14:40:42 -07:00
|
|
|
if (!this.isVisible(element))
|
2020-02-25 07:06:20 -08:00
|
|
|
return 'Element is not visible';
|
|
|
|
if (element.nodeName.toLowerCase() === 'input') {
|
|
|
|
const input = element as HTMLInputElement;
|
2020-04-07 10:07:06 -07:00
|
|
|
const type = (input.getAttribute('type') || '').toLowerCase();
|
|
|
|
const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local']);
|
2020-02-25 07:06:20 -08:00
|
|
|
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
|
2020-04-07 10:07:06 -07:00
|
|
|
if (!kTextInputTypes.has(type) && !kDateTypes.has(type))
|
2020-02-25 07:06:20 -08:00
|
|
|
return 'Cannot fill input of type "' + type + '".';
|
2020-04-07 10:07:06 -07:00
|
|
|
if (type === 'number') {
|
2020-02-25 07:06:20 -08:00
|
|
|
value = value.trim();
|
|
|
|
if (!value || isNaN(Number(value)))
|
|
|
|
return 'Cannot type text into input[type=number].';
|
|
|
|
}
|
|
|
|
if (input.disabled)
|
|
|
|
return 'Cannot fill a disabled input.';
|
|
|
|
if (input.readOnly)
|
|
|
|
return 'Cannot fill a readonly input.';
|
2020-04-07 10:07:06 -07:00
|
|
|
if (kDateTypes.has(type)) {
|
|
|
|
value = value.trim();
|
|
|
|
input.focus();
|
|
|
|
input.value = value;
|
|
|
|
if (input.value !== value)
|
|
|
|
return `Malformed ${type} "${value}"`;
|
|
|
|
element.dispatchEvent(new Event('input', { 'bubbles': true }));
|
|
|
|
element.dispatchEvent(new Event('change', { 'bubbles': true }));
|
|
|
|
return false; // We have already changed the value, no need to input it.
|
|
|
|
}
|
2020-04-14 17:09:26 -07:00
|
|
|
} else if (element.nodeName.toLowerCase() === 'textarea') {
|
|
|
|
const textarea = element as HTMLTextAreaElement;
|
|
|
|
if (textarea.disabled)
|
|
|
|
return 'Cannot fill a disabled textarea.';
|
|
|
|
if (textarea.readOnly)
|
|
|
|
return 'Cannot fill a readonly textarea.';
|
|
|
|
} else if (!element.isContentEditable) {
|
|
|
|
return 'Element is not an <input>, <textarea> or [contenteditable] element.';
|
|
|
|
}
|
|
|
|
return this.selectText(node);
|
|
|
|
}
|
|
|
|
|
|
|
|
selectText(node: Node) {
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
return 'Node is not of type HTMLElement';
|
|
|
|
const element = node as HTMLElement;
|
|
|
|
if (!this.isVisible(element))
|
|
|
|
return 'Element is not visible';
|
|
|
|
if (element.nodeName.toLowerCase() === 'input') {
|
|
|
|
const input = element as HTMLInputElement;
|
2020-02-25 07:06:20 -08:00
|
|
|
input.select();
|
|
|
|
input.focus();
|
2020-04-07 10:07:06 -07:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
if (element.nodeName.toLowerCase() === 'textarea') {
|
2020-02-25 07:06:20 -08:00
|
|
|
const textarea = element as HTMLTextAreaElement;
|
|
|
|
textarea.selectionStart = 0;
|
|
|
|
textarea.selectionEnd = textarea.value.length;
|
|
|
|
textarea.focus();
|
2020-04-07 10:07:06 -07:00
|
|
|
return true;
|
|
|
|
}
|
2020-04-14 17:09:26 -07:00
|
|
|
const range = element.ownerDocument!.createRange();
|
|
|
|
range.selectNodeContents(element);
|
|
|
|
const selection = element.ownerDocument!.defaultView!.getSelection();
|
|
|
|
if (!selection)
|
|
|
|
return 'Element belongs to invisible iframe.';
|
|
|
|
selection.removeAllRanges();
|
|
|
|
selection.addRange(range);
|
|
|
|
element.focus();
|
|
|
|
return true;
|
2020-02-25 07:06:20 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
isCheckboxChecked(node: Node) {
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
|
|
throw new Error('Not a checkbox or radio button');
|
|
|
|
|
|
|
|
let element: Element | undefined = node as Element;
|
|
|
|
if (element.getAttribute('role') === 'checkbox')
|
|
|
|
return element.getAttribute('aria-checked') === 'true';
|
|
|
|
|
|
|
|
if (element.nodeName === 'LABEL') {
|
|
|
|
const forId = element.getAttribute('for');
|
|
|
|
if (forId && element.ownerDocument)
|
|
|
|
element = element.ownerDocument.querySelector(`input[id="${forId}"]`) || undefined;
|
|
|
|
else
|
|
|
|
element = element.querySelector('input[type=checkbox],input[type=radio]') || undefined;
|
|
|
|
}
|
|
|
|
if (element && element.nodeName === 'INPUT') {
|
|
|
|
const type = element.getAttribute('type');
|
|
|
|
if (type && (type.toLowerCase() === 'checkbox' || type.toLowerCase() === 'radio'))
|
|
|
|
return (element as HTMLInputElement).checked;
|
|
|
|
}
|
|
|
|
throw new Error('Not a checkbox');
|
|
|
|
}
|
|
|
|
|
2020-03-03 17:14:00 -08:00
|
|
|
async waitForDisplayedAtStablePosition(node: Node, timeout: number) {
|
2020-02-25 07:06:20 -08:00
|
|
|
if (!node.isConnected)
|
|
|
|
throw new Error('Element is not attached to the DOM');
|
|
|
|
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
|
|
|
if (!element)
|
|
|
|
throw new Error('Element is not attached to the DOM');
|
|
|
|
|
|
|
|
let lastRect: types.Rect | undefined;
|
|
|
|
let counter = 0;
|
2020-03-25 14:08:46 -07:00
|
|
|
const result = await this.poll('raf', timeout, () => {
|
2020-02-25 07:06:20 -08:00
|
|
|
// First raf happens in the same animation frame as evaluation, so it does not produce
|
|
|
|
// any client rect difference compared to synchronous call. We skip the synchronous call
|
|
|
|
// and only force layout during actual rafs as a small optimisation.
|
|
|
|
if (++counter === 1)
|
|
|
|
return false;
|
|
|
|
const clientRect = element.getBoundingClientRect();
|
|
|
|
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
|
2020-03-02 14:26:38 -08:00
|
|
|
const isDisplayedAndStable = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height && rect.width > 0 && rect.height > 0;
|
2020-02-25 07:06:20 -08:00
|
|
|
lastRect = rect;
|
2020-03-02 14:26:38 -08:00
|
|
|
return isDisplayedAndStable;
|
2020-02-25 07:06:20 -08:00
|
|
|
});
|
2020-03-03 17:14:00 -08:00
|
|
|
if (!result)
|
2020-04-07 14:35:34 -07:00
|
|
|
throw new Error(`waiting for element to be displayed and not moving failed: timeout exceeded`);
|
2020-02-25 07:06:20 -08:00
|
|
|
}
|
|
|
|
|
2020-03-03 17:14:00 -08:00
|
|
|
async waitForHitTargetAt(node: Node, timeout: number, point: types.Point) {
|
2020-04-06 20:44:54 -07:00
|
|
|
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
|
|
|
while (element && window.getComputedStyle(element).pointerEvents === 'none')
|
|
|
|
element = element.parentElement;
|
2020-02-25 07:06:20 -08:00
|
|
|
if (!element)
|
|
|
|
throw new Error('Element is not attached to the DOM');
|
2020-03-25 14:08:46 -07:00
|
|
|
const result = await this.poll('raf', timeout, () => {
|
|
|
|
let hitElement = this._deepElementFromPoint(document, point.x, point.y);
|
2020-02-25 07:06:20 -08:00
|
|
|
while (hitElement && hitElement !== element)
|
2020-03-25 14:08:46 -07:00
|
|
|
hitElement = this._parentElementOrShadowHost(hitElement);
|
2020-02-25 07:06:20 -08:00
|
|
|
return hitElement === element;
|
|
|
|
});
|
2020-03-03 17:14:00 -08:00
|
|
|
if (!result)
|
2020-04-07 14:35:34 -07:00
|
|
|
throw new Error(`waiting for element to receive mouse events failed: timeout exceeded`);
|
2020-02-25 07:06:20 -08:00
|
|
|
}
|
2020-03-25 14:08:46 -07:00
|
|
|
|
|
|
|
private _parentElementOrShadowHost(element: Element): Element | undefined {
|
|
|
|
if (element.parentElement)
|
|
|
|
return element.parentElement;
|
|
|
|
if (!element.parentNode)
|
|
|
|
return;
|
|
|
|
if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host)
|
|
|
|
return (element.parentNode as ShadowRoot).host;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _deepElementFromPoint(document: Document, x: number, y: number): Element | undefined {
|
|
|
|
let container: Document | ShadowRoot | null = document;
|
|
|
|
let element: Element | undefined;
|
|
|
|
while (container) {
|
|
|
|
const innerElement = container.elementFromPoint(x, y) as Element | undefined;
|
|
|
|
if (!innerElement || element === innerElement)
|
|
|
|
break;
|
|
|
|
element = innerElement;
|
|
|
|
container = element.shadowRoot;
|
|
|
|
}
|
|
|
|
return element;
|
|
|
|
}
|
2019-11-21 14:43:30 -08:00
|
|
|
}
|
|
|
|
|
2019-11-22 15:36:17 -08:00
|
|
|
export default Injected;
|