Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: refactor actionability checks #5368

Merged
merged 1 commit into from
Feb 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/server/common/domErrors.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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));
dgozman marked this conversation as resolved.
Show resolved Hide resolved
}, 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));
dgozman marked this conversation as resolved.
Show resolved Hide resolved
}, 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
Original file line number Diff line number Diff line change
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));
dgozman marked this conversation as resolved.
Show resolved Hide resolved
}

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