Skip to content

Commit

Permalink
chore: refactor actionability checks (#5368)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozman committed Feb 10, 2021
1 parent 38209c6 commit b4b14ea
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 432 deletions.
3 changes: 2 additions & 1 deletion src/server/common/domErrors.ts
Expand Up @@ -22,6 +22,7 @@ export type FatalDOMError =
'error:notfillablenumberinput' |
'error:notvaliddate' |
'error:notinput' |
'error:notselect';
'error:notselect' |
'error:notcheckbox';

export type RetargetableDOMError = 'error:notconnected';
180 changes: 52 additions & 128 deletions src/server/dom.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
Expand All @@ -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());
Expand All @@ -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());
Expand Down Expand Up @@ -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';
}

Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -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'> {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
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);
});
}, 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) => {
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.isCheckboxChecked(element);
return injected.checkElementState(element, state);
});
}, selector.parsed);
}, { parsed: selector.parsed, state });
}
38 changes: 16 additions & 22 deletions src/server/frames.ts
Expand Up @@ -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>;
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 = {}) {
Expand Down

0 comments on commit b4b14ea

Please sign in to comment.