chore: refactor actionability checks (#5368)

This commit is contained in:
Dmitry Gozman 2021-02-10 12:36:26 -08:00 committed by GitHub
parent 38209c675c
commit b4b14eab69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 277 additions and 436 deletions

View File

@ -22,6 +22,7 @@ export type FatalDOMError =
'error:notfillablenumberinput' |
'error:notvaliddate' |
'error:notinput' |
'error:notselect';
'error:notselect' |
'error:notcheckbox';
export type RetargetableDOMError = 'error:notconnected';

View File

@ -16,7 +16,7 @@
import * as frames from './frames';
import { assert } from '../utils/utils';
import type { InjectedScript, InjectedScriptPoll } from './injected/injectedScript';
import type { ElementStateWithoutStable, InjectedScript, InjectedScriptPoll } from './injected/injectedScript';
import * as injectedScriptSource from '../generated/injectedScriptSource';
import * as js from './javascript';
import { Page } from './page';
@ -85,9 +85,11 @@ export class FrameExecutionContext extends js.ExecutionContext {
const source = `
(() => {
${injectedScriptSource.source}
return new pwExport([
${custom.join(',\n')}
]);
return new pwExport(
${this.frame._page._delegate.rafCountForStablePosition()},
${!!process.env.PW_USE_TIMEOUT_FOR_RAF},
[${custom.join(',\n')}]
);
})();
`;
this._injectedScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId));
@ -451,12 +453,14 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async _selectOption(progress: Progress, elements: ElementHandle[], values: types.SelectOption[], options: types.NavigatingActionWaitOptions): Promise<string[] | 'error:notconnected'> {
const selectOptions = [...elements, ...values];
const optionsToSelect = [...elements, ...values];
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
progress.log(' selecting specified option(s)');
await progress.checkpoint('before');
const poll = await this._evaluateHandleInUtility(([injected, node, selectOptions]) => injected.waitForOptionsAndSelect(node, selectOptions), selectOptions);
const poll = await this._evaluateHandleInUtility(([injected, node, optionsToSelect]) => {
return injected.waitForElementStatesAndPerformAction(node, ['visible', 'enabled'], injected.selectOptions.bind(injected, optionsToSelect));
}, optionsToSelect);
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const result = throwFatalDOMError(await pollHandler.finish());
await this._page._doSlowMo();
@ -477,7 +481,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.log(' waiting for element to be visible, enabled and editable');
const poll = await this._evaluateHandleInUtility(([injected, node, value]) => {
return injected.waitForEnabledAndFill(node, value);
return injected.waitForElementStatesAndPerformAction(node, ['visible', 'enabled', 'editable'], injected.fill.bind(injected, value));
}, value);
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const filled = throwFatalDOMError(await pollHandler.finish());
@ -504,7 +508,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return controller.run(async progress => {
progress.throwIfAborted(); // Avoid action that has side-effects.
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForVisibleAndSelectText(node);
return injected.waitForElementStatesAndPerformAction(node, ['visible'], injected.selectText.bind(injected));
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const result = throwFatalDOMError(await pollHandler.finish());
@ -615,13 +619,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async _setChecked(progress: Progress, state: boolean, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
if (await this._evaluateInUtility(([injected, node]) => injected.isCheckboxChecked(node), {}) === state)
const isChecked = async () => {
const result = await this._evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'checked'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
};
if (await isChecked() === state)
return 'done';
const result = await this._click(progress, options);
if (result !== 'done')
return result;
if (await this._evaluateInUtility(([injected, node]) => injected.isCheckboxChecked(node), {}) !== state)
throw new Error('Unable to click checkbox');
if (await isChecked() !== state)
throw new Error('Clicking the checkbox did not change its state');
return 'done';
}
@ -661,94 +669,44 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async isVisible(): Promise<boolean> {
return this._evaluateInUtility(([injected, node]) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Node as Element : node.parentElement;
return element ? injected.isVisible(element) : false;
}, {});
const result = await this._evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'visible'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}
async isHidden(): Promise<boolean> {
return !(await this.isVisible());
const result = await this._evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'hidden'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}
async isEnabled(): Promise<boolean> {
return !(await this.isDisabled());
const result = await this._evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'enabled'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}
async isDisabled(): Promise<boolean> {
return this._evaluateInUtility(([injected, node]) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Node as Element : node.parentElement;
return element ? injected.isElementDisabled(element) : false;
}, {});
const result = await this._evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'disabled'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}
async isEditable(): Promise<boolean> {
return this._evaluateInUtility(([injected, node]) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Node as Element : node.parentElement;
return element ? !injected.isElementDisabled(element) && !injected.isElementReadOnly(element) : false;
}, {});
const result = await this._evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'editable'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}
async isChecked(): Promise<boolean> {
return this._evaluateInUtility(([injected, node]) => {
return injected.isCheckboxChecked(node);
}, {});
const result = await this._evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'checked'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}
async waitForElementState(metadata: CallMetadata, state: 'visible' | 'hidden' | 'stable' | 'enabled' | 'disabled' | 'editable', options: types.TimeoutOptions = {}): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
progress.log(` waiting for element to be ${state}`);
if (state === 'visible') {
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForNodeVisible(node);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
assertDone(throwRetargetableDOMError(await pollHandler.finish()));
return;
}
if (state === 'hidden') {
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForNodeHidden(node);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
assertDone(await pollHandler.finish());
return;
}
if (state === 'enabled') {
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForNodeEnabled(node);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
assertDone(throwRetargetableDOMError(await pollHandler.finish()));
return;
}
if (state === 'disabled') {
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForNodeDisabled(node);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
assertDone(throwRetargetableDOMError(await pollHandler.finish()));
return;
}
if (state === 'editable') {
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForNodeEnabled(node, true /* waitForEnabled */);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
assertDone(throwRetargetableDOMError(await pollHandler.finish()));
return;
}
if (state === 'stable') {
const rafCount = this._page._delegate.rafCountForStablePosition();
const poll = await this._evaluateHandleInUtility(([injected, node, rafOptions]) => {
return injected.waitForDisplayedAtStablePosition(node, rafOptions, false /* waitForEnabled */);
}, { rafCount, useTimeout: !!process.env.PW_USE_TIMEOUT_FOR_RAF });
const pollHandler = new InjectedScriptPollHandler(progress, poll);
assertDone(throwRetargetableDOMError(await pollHandler.finish()));
return;
}
throw new Error(`state: expected one of (visible|hidden|stable|enabled|disabled|editable)`);
const poll = await this._evaluateHandleInUtility(([injected, node, state]) => {
return injected.waitForElementStatesAndPerformAction(node, [state], () => 'done' as const);
}, state);
const pollHandler = new InjectedScriptPollHandler(progress, poll);
assertDone(throwRetargetableDOMError(throwFatalDOMError(await pollHandler.finish())));
}, this._page._timeoutSettings.timeout(options));
}
@ -785,20 +743,20 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _waitForDisplayedAtStablePosition(progress: Progress, waitForEnabled: boolean): Promise<'error:notconnected' | 'done'> {
if (waitForEnabled)
progress.log(` waiting for element to be visible, enabled and not moving`);
progress.log(` waiting for element to be visible, enabled and stable`);
else
progress.log(` waiting for element to be visible and not moving`);
const rafCount = this._page._delegate.rafCountForStablePosition();
const poll = this._evaluateHandleInUtility(([injected, node, { rafOptions, waitForEnabled }]) => {
return injected.waitForDisplayedAtStablePosition(node, rafOptions, waitForEnabled);
}, { rafOptions: { rafCount, useTimeout: !!process.env.PW_USE_TIMEOUT_FOR_RAF }, waitForEnabled });
progress.log(` waiting for element to be visible and stable`);
const poll = this._evaluateHandleInUtility(([injected, node, waitForEnabled]) => {
return injected.waitForElementStatesAndPerformAction(node,
waitForEnabled ? ['visible', 'stable', 'enabled'] : ['visible', 'stable'], () => 'done' as const);
}, waitForEnabled);
const pollHandler = new InjectedScriptPollHandler(progress, await poll);
const result = await pollHandler.finish();
if (waitForEnabled)
progress.log(' element is visible, enabled and does not move');
progress.log(' element is visible, enabled and stable');
else
progress.log(' element is visible and does not move');
return result;
progress.log(' element is visible and stable');
return throwFatalDOMError(result);
}
async _checkHitTargetAt(point: types.Point): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> {
@ -898,10 +856,12 @@ export function throwFatalDOMError<T>(result: T | FatalDOMError): T {
throw new Error('Node is not an HTMLInputElement');
if (result === 'error:notselect')
throw new Error('Element is not a <select> element.');
if (result === 'error:notcheckbox')
throw new Error('Not a checkbox or radio button');
return result;
}
function throwRetargetableDOMError<T>(result: T | RetargetableDOMError): T {
export function throwRetargetableDOMError<T>(result: T | RetargetableDOMError): T {
if (result === 'error:notconnected')
throw new Error('Element is not attached to the DOM');
return result;
@ -1032,50 +992,14 @@ export function getAttributeTask(selector: SelectorInfo, name: string): Schedula
}, { parsed: selector.parsed, name });
}
export function visibleTask(selector: SelectorInfo): SchedulableTask<boolean> {
return injectedScript => injectedScript.evaluateHandle((injected, parsed) => {
export function elementStateTask(selector: SelectorInfo, state: ElementStateWithoutStable): SchedulableTask<boolean | 'error:notconnected' | FatalDOMError> {
return injectedScript => injectedScript.evaluateHandle((injected, { parsed, state }) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
if (!element)
return continuePolling;
progress.log(` selector resolved to ${injected.previewNode(element)}`);
return injected.isVisible(element);
return injected.checkElementState(element, state);
});
}, selector.parsed);
}
export function disabledTask(selector: SelectorInfo): SchedulableTask<boolean> {
return injectedScript => injectedScript.evaluateHandle((injected, parsed) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
if (!element)
return continuePolling;
progress.log(` selector resolved to ${injected.previewNode(element)}`);
return injected.isElementDisabled(element);
});
}, selector.parsed);
}
export function editableTask(selector: SelectorInfo): SchedulableTask<boolean> {
return injectedScript => injectedScript.evaluateHandle((injected, parsed) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
if (!element)
return continuePolling;
progress.log(` selector resolved to ${injected.previewNode(element)}`);
return !injected.isElementDisabled(element) && !injected.isElementReadOnly(element);
});
}, selector.parsed);
}
export function checkedTask(selector: SelectorInfo): SchedulableTask<boolean> {
return injectedScript => injectedScript.evaluateHandle((injected, parsed) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
if (!element)
return continuePolling;
progress.log(` selector resolved to ${injected.previewNode(element)}`);
return injected.isCheckboxChecked(element);
});
}, selector.parsed);
}, { parsed: selector.parsed, state });
}

View File

@ -27,6 +27,7 @@ import { Progress, ProgressController } from './progress';
import { assert, makeWaitForNextTask } from '../utils/utils';
import { debugLogger } from '../utils/debugLogger';
import { CallMetadata, SdkObject } from './instrumentation';
import { ElementStateWithoutStable } from './injected/injectedScript';
type ContextData = {
contextPromise: Promise<dom.FrameExecutionContext>;
@ -944,6 +945,17 @@ export class Frame extends SdkObject {
}, this._page._timeoutSettings.timeout(options));
}
private async _checkElementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.TimeoutOptions = {}): Promise<boolean> {
const controller = new ProgressController(metadata, this);
const info = this._page.selectors._parseSelector(selector);
const task = dom.elementStateTask(info, state);
const result = await controller.run(async progress => {
progress.log(` checking "${state}" state of "${selector}"`);
return this._scheduleRerunnableTask(progress, info.world, task);
}, this._page._timeoutSettings.timeout(options));
return dom.throwFatalDOMError(dom.throwRetargetableDOMError(result));
}
async isVisible(metadata: CallMetadata, selector: string, options: types.TimeoutOptions = {}): Promise<boolean> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
@ -958,37 +970,19 @@ export class Frame extends SdkObject {
}
async isDisabled(metadata: CallMetadata, selector: string, options: types.TimeoutOptions = {}): Promise<boolean> {
const controller = new ProgressController(metadata, this);
const info = this._page.selectors._parseSelector(selector);
const task = dom.disabledTask(info);
return controller.run(async progress => {
progress.log(` checking disabled state of "${selector}"`);
return this._scheduleRerunnableTask(progress, info.world, task);
}, this._page._timeoutSettings.timeout(options));
return this._checkElementState(metadata, selector, 'disabled', options);
}
async isEnabled(metadata: CallMetadata, selector: string, options: types.TimeoutOptions = {}): Promise<boolean> {
return !(await this.isDisabled(metadata, selector, options));
return this._checkElementState(metadata, selector, 'enabled', options);
}
async isEditable(metadata: CallMetadata, selector: string, options: types.TimeoutOptions = {}): Promise<boolean> {
const controller = new ProgressController(metadata, this);
const info = this._page.selectors._parseSelector(selector);
const task = dom.editableTask(info);
return controller.run(async progress => {
progress.log(` checking editable state of "${selector}"`);
return this._scheduleRerunnableTask(progress, info.world, task);
}, this._page._timeoutSettings.timeout(options));
return this._checkElementState(metadata, selector, 'editable', options);
}
async isChecked(metadata: CallMetadata, selector: string, options: types.TimeoutOptions = {}): Promise<boolean> {
const controller = new ProgressController(metadata, this);
const info = this._page.selectors._parseSelector(selector);
const task = dom.checkedTask(info);
return controller.run(async progress => {
progress.log(` checking checked state of "${selector}"`);
return this._scheduleRerunnableTask(progress, info.world, task);
}, this._page._timeoutSettings.timeout(options));
return this._checkElementState(metadata, selector, 'checked', options);
}
async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions = {}) {

View File

@ -38,11 +38,16 @@ export type InjectedScriptPoll<T> = {
cancel: () => void,
};
export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked';
export type ElementState = ElementStateWithoutStable | 'stable';
export class InjectedScript {
private _enginesV1: Map<string, SelectorEngine>;
_evaluator: SelectorEvaluatorImpl;
private _stableRafCount: number;
private _replaceRafWithTimeout: boolean;
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
constructor(stableRafCount: number, replaceRafWithTimeout: boolean, customEngines: { name: string, engine: SelectorEngine}[]) {
this._enginesV1 = new Map();
this._enginesV1.set('xpath', XPathEngine);
this._enginesV1.set('xpath:light', XPathEngine);
@ -61,6 +66,8 @@ export class InjectedScript {
// No custom engines in V2 for now.
this._evaluator = new SelectorEvaluatorImpl(new Map());
this._stableRafCount = stableRafCount;
this._replaceRafWithTimeout = replaceRafWithTimeout;
}
parseSelector(selector: string): ParsedSelector {
@ -319,148 +326,206 @@ export class InjectedScript {
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
}
waitForOptionsAndSelect(node: Node, optionsToSelect: (Node | { value?: string, label?: string, index?: number })[]): InjectedScriptPoll<string[] | 'error:notconnected' | FatalDOMError> {
return this.pollRaf((progress, continuePolling) => {
const element = this.findLabelTarget(node as Element);
if (!element || !element.isConnected)
return 'error:notconnected';
if (element.nodeName.toLowerCase() !== 'select')
return 'error:notselect';
const select = element as HTMLSelectElement;
const options = Array.from(select.options);
const selectedOptions = [];
let remainingOptionsToSelect = optionsToSelect.slice();
for (let index = 0; index < options.length; index++) {
const option = options[index];
const filter = (optionToSelect: Node | { value?: string, label?: string, index?: number }) => {
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 (!remainingOptionsToSelect.some(filter))
private _retarget(node: Node): Element | null {
let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!element)
return null;
element = element.closest('button, [role=button], [role=checkbox], [role=radio]') || element;
if (!element.matches('input, textarea, button, select, [role=button], [role=checkbox], [role=radio]') &&
!(element as any).isContentEditable) {
// Go up to the label that might be connected to the input/textarea.
element = element.closest('label') || element;
}
if (element.nodeName === 'LABEL')
element = (element as HTMLLabelElement).control || element;
return element;
}
waitForElementStatesAndPerformAction<T>(node: Node, states: ElementState[],
callback: (element: Element | null, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected' | FatalDOMError> {
let lastRect: { x: number, y: number, width: number, height: number } | undefined;
let counter = 0;
let samePositionCounter = 0;
let lastTime = 0;
const predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => {
const element = this._retarget(node);
for (const state of states) {
if (state !== 'stable') {
const result = this._checkElementState(element, state);
if (typeof result !== 'boolean')
return result;
if (!result) {
progress.logRepeating(` element is not ${state} - waiting...`);
return continuePolling;
}
continue;
selectedOptions.push(option);
if (select.multiple) {
remainingOptionsToSelect = remainingOptionsToSelect.filter(o => !filter(o));
} else {
remainingOptionsToSelect = [];
break;
}
if (!element)
return 'error:notconnected';
// 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 continuePolling;
// Drop frames that are shorter than 16ms - WebKit Win bug.
const time = performance.now();
if (this._stableRafCount > 1 && time - lastTime < 15)
return continuePolling;
lastTime = time;
const clientRect = element.getBoundingClientRect();
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
const samePosition = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height;
if (samePosition)
++samePositionCounter;
else
samePositionCounter = 0;
const isStable = samePositionCounter >= this._stableRafCount;
const isStableForLogs = isStable || !lastRect;
lastRect = rect;
if (!isStableForLogs)
progress.logRepeating(` element is not stable - waiting...`);
if (!isStable)
return continuePolling;
}
if (remainingOptionsToSelect.length) {
progress.logRepeating(' did not find some options - waiting... ');
return continuePolling;
}
select.value = undefined as any;
selectedOptions.forEach(option => option.selected = true);
progress.log(' selected specified option(s)');
select.dispatchEvent(new Event('input', { 'bubbles': true }));
select.dispatchEvent(new Event('change', { 'bubbles': true }));
return selectedOptions.map(option => option.value);
});
return callback(element, progress, continuePolling);
};
if (this._replaceRafWithTimeout)
return this.pollInterval(16, predicate);
else
return this.pollRaf(predicate);
}
waitForEnabledAndFill(node: Node, value: string): InjectedScriptPoll<FatalDOMError | 'error:notconnected' | 'needsinput' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return 'error:notelement';
private _checkElementState(element: Element | null, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
if (!element || !element.isConnected) {
if (state === 'hidden')
return true;
return 'error:notconnected';
}
if (state === 'visible')
return this.isVisible(element);
if (state === 'hidden')
return !this.isVisible(element);
if (node && node.nodeName.toLowerCase() !== 'input' &&
node.nodeName.toLowerCase() !== 'textarea' &&
!(node as any).isContentEditable) {
// Go up to the label that might be connected to the input/textarea.
node = (node as Element).closest('label') || node;
}
const element = this.findLabelTarget(node as Element);
const disabled = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(element.nodeName) && element.hasAttribute('disabled');
if (state === 'disabled')
return disabled;
if (state === 'enabled')
return !disabled;
if (element && !element.isConnected)
return 'error:notconnected';
if (!element || !this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return continuePolling;
}
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
const type = input.type.toLowerCase();
const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local', 'month', 'week']);
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
if (!kTextInputTypes.has(type) && !kDateTypes.has(type)) {
progress.log(` input of type "${type}" cannot be filled`);
return 'error:notfillableinputtype';
}
if (type === 'number') {
value = value.trim();
if (isNaN(Number(value)))
return 'error:notfillablenumberinput';
}
if (input.disabled) {
progress.logRepeating(' element is disabled - waiting...');
return continuePolling;
}
if (input.readOnly) {
progress.logRepeating(' element is readonly - waiting...');
return continuePolling;
}
if (kDateTypes.has(type)) {
value = value.trim();
input.focus();
input.value = value;
if (input.value !== value)
return 'error:notvaliddate';
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return 'done'; // We have already changed the value, no need to input it.
}
} else if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement;
if (textarea.disabled) {
progress.logRepeating(' element is disabled - waiting...');
return continuePolling;
}
if (textarea.readOnly) {
progress.logRepeating(' element is readonly - waiting...');
return continuePolling;
}
} else if (!(element as HTMLElement).isContentEditable) {
return 'error:notfillableelement';
}
const result = this._selectText(element);
if (result === 'error:notvisible') {
progress.logRepeating(' element is not visible - waiting...');
return continuePolling;
}
return 'needsinput'; // Still need to input the value.
});
const editable = !(['INPUT', 'TEXTAREA', 'SELECT'].includes(element.nodeName) && element.hasAttribute('readonly'));
if (state === 'editable')
return !disabled && editable;
if (state === 'checked') {
if (element.getAttribute('role') === 'checkbox')
return element.getAttribute('aria-checked') === 'true';
if (element.nodeName !== 'INPUT')
return 'error:notcheckbox';
if (!['radio', 'checkbox'].includes((element as HTMLInputElement).type.toLowerCase()))
return 'error:notcheckbox';
return (element as HTMLInputElement).checked;
}
throw new Error(`Unexpected element state "${state}"`);
}
waitForVisibleAndSelectText(node: Node): InjectedScriptPoll<FatalDOMError | 'error:notconnected' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return 'error:notelement';
if (!node.isConnected)
return 'error:notconnected';
const element = node as Element;
if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return continuePolling;
}
const result = this._selectText(element);
if (result === 'error:notvisible') {
progress.logRepeating(' element is not visible - waiting...');
return continuePolling;
}
return result;
});
checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
const element = this._retarget(node);
return this._checkElementState(element, state);
}
private _selectText(element: Element): 'error:notvisible' | 'error:notconnected' | 'done' {
selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[],
element: Element | null, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | FatalDOMError | symbol {
if (!element)
return 'error:notconnected';
if (element.nodeName.toLowerCase() !== 'select')
return 'error:notselect';
const select = element as HTMLSelectElement;
const options = Array.from(select.options);
const selectedOptions = [];
let remainingOptionsToSelect = optionsToSelect.slice();
for (let index = 0; index < options.length; index++) {
const option = options[index];
const filter = (optionToSelect: Node | { value?: string, label?: string, index?: number }) => {
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 (!remainingOptionsToSelect.some(filter))
continue;
selectedOptions.push(option);
if (select.multiple) {
remainingOptionsToSelect = remainingOptionsToSelect.filter(o => !filter(o));
} else {
remainingOptionsToSelect = [];
break;
}
}
if (remainingOptionsToSelect.length) {
progress.logRepeating(' did not find some options - waiting... ');
return continuePolling;
}
select.value = undefined as any;
selectedOptions.forEach(option => option.selected = true);
progress.log(' selected specified option(s)');
select.dispatchEvent(new Event('input', { 'bubbles': true }));
select.dispatchEvent(new Event('change', { 'bubbles': true }));
return selectedOptions.map(option => option.value);
}
fill(value: string, element: Element | null, progress: InjectedScriptProgress): FatalDOMError | 'error:notconnected' | 'needsinput' | 'done' {
if (!element)
return 'error:notconnected';
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
const type = input.type.toLowerCase();
const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local', 'month', 'week']);
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
if (!kTextInputTypes.has(type) && !kDateTypes.has(type)) {
progress.log(` input of type "${type}" cannot be filled`);
return 'error:notfillableinputtype';
}
if (type === 'number') {
value = value.trim();
if (isNaN(Number(value)))
return 'error:notfillablenumberinput';
}
if (kDateTypes.has(type)) {
value = value.trim();
input.focus();
input.value = value;
if (input.value !== value)
return 'error:notvaliddate';
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return 'done'; // We have already changed the value, no need to input it.
}
} else if (element.nodeName.toLowerCase() === 'textarea') {
// Nothing to check here.
} else if (!(element as HTMLElement).isContentEditable) {
return 'error:notfillableelement';
}
this.selectText(element);
return 'needsinput'; // Still need to input the value.
}
selectText(element: Element | null): 'error:notconnected' | 'done' {
if (!element)
return 'error:notconnected';
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
input.select();
@ -477,70 +542,14 @@ export class InjectedScript {
const range = element.ownerDocument.createRange();
range.selectNodeContents(element);
const selection = element.ownerDocument.defaultView!.getSelection();
if (!selection)
return 'error:notvisible';
selection.removeAllRanges();
selection.addRange(range);
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
(element as HTMLElement | SVGElement).focus();
return 'done';
}
waitForNodeVisible(node: Node): InjectedScriptPoll<'error:notconnected' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!node.isConnected || !element)
return 'error:notconnected';
if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return continuePolling;
}
return 'done';
});
}
waitForNodeHidden(node: Node): InjectedScriptPoll<'done'> {
return this.pollRaf((progress, continuePolling) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!node.isConnected || !element)
return 'done';
if (this.isVisible(element)) {
progress.logRepeating(' element is visible - waiting...');
return continuePolling;
}
return 'done';
});
}
waitForNodeEnabled(node: Node, waitForEditable?: boolean): InjectedScriptPoll<'error:notconnected' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!node.isConnected || !element)
return 'error:notconnected';
if (this.isElementDisabled(element)) {
progress.logRepeating(' element is not enabled - waiting...');
return continuePolling;
}
if (waitForEditable && this.isElementReadOnly(element)) {
progress.logRepeating(' element is readonly - waiting...');
return continuePolling;
}
return 'done';
});
}
waitForNodeDisabled(node: Node): InjectedScriptPoll<'error:notconnected' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!node.isConnected || !element)
return 'error:notconnected';
if (!this.isElementDisabled(element)) {
progress.logRepeating(' element is enabled - waiting...');
return continuePolling;
}
return 'done';
});
}
focusNode(node: Node, resetSelectionIfNotFocused?: boolean): FatalDOMError | 'error:notconnected' | 'done' {
if (!node.isConnected)
return 'error:notconnected';
@ -560,24 +569,6 @@ export class InjectedScript {
return 'done';
}
findLabelTarget(element: Element): Element | undefined {
return element.nodeName === 'LABEL' ? (element as HTMLLabelElement).control || undefined : element;
}
isCheckboxChecked(node: Node) {
if (node.nodeType !== Node.ELEMENT_NODE)
throw new Error('Not a checkbox or radio button');
const element = node as Element;
if (element.getAttribute('role') === 'checkbox')
return element.getAttribute('aria-checked') === 'true';
const input = this.findLabelTarget(element);
if (!input || input.nodeName !== 'INPUT')
throw new Error('Not a checkbox or radio button');
if (!['radio', 'checkbox'].includes((input as HTMLInputElement).type.toLowerCase()))
throw new Error('Not a checkbox or radio button');
return (input as HTMLInputElement).checked;
}
setInputFiles(node: Node, payloads: { name: string, mimeType: string, buffer: string }[]) {
if (node.nodeType !== Node.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
@ -601,66 +592,6 @@ export class InjectedScript {
input.dispatchEvent(new Event('change', { 'bubbles': true }));
}
waitForDisplayedAtStablePosition(node: Node, rafOptions: { rafCount: number, useTimeout?: boolean }, waitForEnabled: boolean): InjectedScriptPoll<'error:notconnected' | 'done'> {
let lastRect: { x: number, y: number, width: number, height: number } | undefined;
let counter = 0;
let samePositionCounter = 0;
let lastTime = 0;
const predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => {
// 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 continuePolling;
if (!node.isConnected)
return 'error:notconnected';
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element)
return 'error:notconnected';
// Drop frames that are shorter than 16ms - WebKit Win bug.
const time = performance.now();
if (rafOptions.rafCount > 1 && time - lastTime < 15)
return continuePolling;
lastTime = time;
// Note: this logic should be similar to isVisible() to avoid surprises.
const clientRect = element.getBoundingClientRect();
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
const samePosition = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height;
const isDisplayed = rect.width > 0 && rect.height > 0;
if (samePosition)
++samePositionCounter;
else
samePositionCounter = 0;
const isStable = samePositionCounter >= rafOptions.rafCount;
const isStableForLogs = isStable || !lastRect;
lastRect = rect;
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined;
const isVisible = !!style && style.visibility !== 'hidden';
const isDisabled = waitForEnabled && this.isElementDisabled(element);
if (isDisplayed && isStable && isVisible && !isDisabled)
return 'done';
if (!isDisplayed || !isVisible)
progress.logRepeating(` element is not visible - waiting...`);
else if (!isStableForLogs)
progress.logRepeating(` element is moving - waiting...`);
else if (isDisabled)
progress.logRepeating(` element is disabled - waiting...`);
return continuePolling;
};
if (rafOptions.useTimeout)
return this.pollInterval(16, predicate);
else
return this.pollRaf(predicate);
}
checkHitTargetAt(node: Node, point: { x: number, y: number }): 'error:notconnected' | 'done' | { hitTargetDescription: string } {
let element: Element | null | undefined = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element || !element.isConnected)
@ -708,16 +639,6 @@ export class InjectedScript {
node.dispatchEvent(event);
}
isElementDisabled(element: Element): boolean {
const elementOrButton = element.closest('button, [role=button]') || element;
return ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
}
isElementReadOnly(element: Element): boolean {
const target = this.findLabelTarget(element);
return !!target && ['INPUT', 'TEXTAREA'].includes(target.nodeName) && target.hasAttribute('readonly');
}
deepElementFromPoint(document: Document, x: number, y: number): Element | undefined {
let container: Document | ShadowRoot | null = document;
let element: Element | undefined;

View File

@ -32,5 +32,5 @@ it('should timeout waiting for button to be enabled', async ({page, server}) =>
const error = await page.click('text=Click target', { timeout: 3000 }).catch(e => e);
expect(await page.evaluate('window.__CLICKED')).toBe(undefined);
expect(error.message).toContain('page.click: Timeout 3000ms exceeded.');
expect(error.message).toContain('element is disabled - waiting');
expect(error.message).toContain('element is not enabled - waiting');
});

View File

@ -22,7 +22,7 @@ it('should timeout waiting for display:none to be gone', async ({page, server})
await page.$eval('button', b => b.style.display = 'none');
const error = await page.click('button', { timeout: 5000 }).catch(e => e);
expect(error.message).toContain('page.click: Timeout 5000ms exceeded.');
expect(error.message).toContain('waiting for element to be visible, enabled and not moving');
expect(error.message).toContain('waiting for element to be visible, enabled and stable');
expect(error.message).toContain('element is not visible - waiting');
});
@ -31,6 +31,6 @@ it('should timeout waiting for visbility:hidden to be gone', async ({page, serve
await page.$eval('button', b => b.style.visibility = 'hidden');
const error = await page.click('button', { timeout: 5000 }).catch(e => e);
expect(error.message).toContain('page.click: Timeout 5000ms exceeded.');
expect(error.message).toContain('waiting for element to be visible, enabled and not moving');
expect(error.message).toContain('waiting for element to be visible, enabled and stable');
expect(error.message).toContain('element is not visible - waiting');
});

View File

@ -26,6 +26,6 @@ it('should timeout waiting for stable position', async ({page, server}) => {
});
const error = await button.click({ timeout: 3000 }).catch(e => e);
expect(error.message).toContain('elementHandle.click: Timeout 3000ms exceeded.');
expect(error.message).toContain('waiting for element to be visible, enabled and not moving');
expect(error.message).toContain('element is moving - waiting');
expect(error.message).toContain('waiting for element to be visible, enabled and stable');
expect(error.message).toContain('element is not stable - waiting');
});

View File

@ -6,6 +6,7 @@
"moduleResolution": "node",
"target": "ESNext",
"strictNullChecks": false,
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,
},
"include": ["**/*.spec.js", "**/*.ts"]