chore: remove FatalDOMError (#9119)

We can now throw stackless errors instead.
Also fixed stackless errors on Firefox.
This commit is contained in:
Dmitry Gozman 2021-09-24 20:51:09 -07:00 committed by GitHub
parent d22dd4a4e7
commit ee25fefb62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 72 additions and 122 deletions

View File

@ -1,30 +0,0 @@
/**
* 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.
*/
export type FatalDOMError =
'error:notelement' |
'error:nothtmlelement' |
'error:notfillableelement' |
'error:notfillableinputtype' |
'error:notfillablenumberinput' |
'error:notvaliddate' |
'error:notinput' |
'error:hasnovalue' |
'error:notselect' |
'error:notcheckbox' |
'error:notmultiplefileinput';
export type RetargetableDOMError = 'error:notconnected';

View File

@ -17,7 +17,6 @@
import * as mime from 'mime'; import * as mime from 'mime';
import * as injectedScriptSource from '../generated/injectedScriptSource'; import * as injectedScriptSource from '../generated/injectedScriptSource';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { FatalDOMError, RetargetableDOMError } from './common/domErrors';
import { isSessionClosedError } from './common/protocolError'; import { isSessionClosedError } from './common/protocolError';
import * as frames from './frames'; import * as frames from './frames';
import type { InjectedScript, InjectedScriptPoll, LogEntry } from './injected/injectedScript'; import type { InjectedScript, InjectedScriptPoll, LogEntry } from './injected/injectedScript';
@ -98,6 +97,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
return new pwExport( return new pwExport(
${this.frame._page._delegate.rafCountForStablePosition()}, ${this.frame._page._delegate.rafCountForStablePosition()},
${!!process.env.PWTEST_USE_TIMEOUT_FOR_RAF}, ${!!process.env.PWTEST_USE_TIMEOUT_FOR_RAF},
"${this.frame._page._browserContext._browser.options.name}",
[${custom.join(',\n')}] [${custom.join(',\n')}]
); );
})(); })();
@ -182,21 +182,21 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async getAttribute(name: string): Promise<string | null> { async getAttribute(name: string): Promise<string | null> {
return throwFatalDOMError(throwRetargetableDOMError(await this.evaluateInUtility(([injeced, node, name]) => { return throwRetargetableDOMError(await this.evaluateInUtility(([injected, node, name]) => {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
return 'error:notelement'; throw injected.createStacklessError('Node is not an element');
const element = node as unknown as Element; const element = node as unknown as Element;
return { value: element.getAttribute(name) }; return { value: element.getAttribute(name) };
}, name))).value; }, name)).value;
} }
async inputValue(): Promise<string> { async inputValue(): Promise<string> {
return throwFatalDOMError(throwRetargetableDOMError(await this.evaluateInUtility(([injeced, node]) => { return throwRetargetableDOMError(await this.evaluateInUtility(([injected, node]) => {
if (node.nodeType !== Node.ELEMENT_NODE || (node.nodeName !== 'INPUT' && node.nodeName !== 'TEXTAREA' && node.nodeName !== 'SELECT')) if (node.nodeType !== Node.ELEMENT_NODE || (node.nodeName !== 'INPUT' && node.nodeName !== 'TEXTAREA' && node.nodeName !== 'SELECT'))
return 'error:hasnovalue'; throw injected.createStacklessError('Node is not an <input>, <textarea> or <select> element');
const element = node as unknown as (HTMLInputElement | HTMLTextAreaElement); const element = node as unknown as (HTMLInputElement | HTMLTextAreaElement);
return { value: element.value }; return { value: element.value };
}, undefined))).value; }, undefined)).value;
} }
async textContent(): Promise<string | null> { async textContent(): Promise<string | null> {
@ -206,23 +206,23 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async innerText(): Promise<string> { async innerText(): Promise<string> {
return throwFatalDOMError(throwRetargetableDOMError(await this.evaluateInUtility(([injected, node]) => { return throwRetargetableDOMError(await this.evaluateInUtility(([injected, node]) => {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
return 'error:notelement'; throw injected.createStacklessError('Node is not an element');
if ((node as unknown as Element).namespaceURI !== 'http://www.w3.org/1999/xhtml') if ((node as unknown as Element).namespaceURI !== 'http://www.w3.org/1999/xhtml')
return 'error:nothtmlelement'; throw injected.createStacklessError('Node is not an HTMLElement');
const element = node as unknown as HTMLElement; const element = node as unknown as HTMLElement;
return { value: element.innerText }; return { value: element.innerText };
}, undefined))).value; }, undefined)).value;
} }
async innerHTML(): Promise<string> { async innerHTML(): Promise<string> {
return throwFatalDOMError(throwRetargetableDOMError(await this.evaluateInUtility(([injected, node]) => { return throwRetargetableDOMError(await this.evaluateInUtility(([injected, node]) => {
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
return 'error:notelement'; throw injected.createStacklessError('Node is not an element');
const element = node as unknown as Element; const element = node as unknown as Element;
return { value: element.innerHTML }; return { value: element.innerHTML };
}, undefined))).value; }, undefined)).value;
} }
async dispatchEvent(type: string, eventInit: Object = {}) { async dispatchEvent(type: string, eventInit: Object = {}) {
@ -505,7 +505,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (poll === 'error:notconnected') if (poll === 'error:notconnected')
return poll; return poll;
const pollHandler = new InjectedScriptPollHandler(progress, poll); const pollHandler = new InjectedScriptPollHandler(progress, poll);
const result = throwFatalDOMError(await pollHandler.finish()); const result = await pollHandler.finish();
await this._page._doSlowMo(); await this._page._doSlowMo();
return result; return result;
}); });
@ -530,7 +530,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (poll === 'error:notconnected') if (poll === 'error:notconnected')
return poll; return poll;
const pollHandler = new InjectedScriptPollHandler(progress, poll); const pollHandler = new InjectedScriptPollHandler(progress, poll);
const filled = throwFatalDOMError(await pollHandler.finish()); const filled = await pollHandler.finish();
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
if (filled === 'error:notconnected') if (filled === 'error:notconnected')
return filled; return filled;
@ -556,7 +556,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return injected.waitForElementStatesAndPerformAction(node, ['visible'], force, injected.selectText.bind(injected)); return injected.waitForElementStatesAndPerformAction(node, ['visible'], force, injected.selectText.bind(injected));
}, options.force); }, options.force);
const pollHandler = new InjectedScriptPollHandler(progress, throwRetargetableDOMError(poll)); const pollHandler = new InjectedScriptPollHandler(progress, throwRetargetableDOMError(poll));
const result = throwFatalDOMError(await pollHandler.finish()); const result = await pollHandler.finish();
assertDone(throwRetargetableDOMError(result)); assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, this._page._timeoutSettings.timeout(options));
} }
@ -574,20 +574,20 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (!payload.mimeType) if (!payload.mimeType)
payload.mimeType = mime.getType(payload.name) || 'application/octet-stream'; payload.mimeType = mime.getType(payload.name) || 'application/octet-stream';
} }
const retargeted = await this.evaluateHandleInUtility(([injected, node, multiple]): FatalDOMError | 'error:notconnected' | Element => { const retargeted = await this.evaluateHandleInUtility(([injected, node, multiple]): 'error:notconnected' | Element => {
const element = injected.retarget(node, 'follow-label'); const element = injected.retarget(node, 'follow-label');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
if (element.tagName !== 'INPUT') if (element.tagName !== 'INPUT')
return 'error:notinput'; throw injected.createStacklessError('Node is not an HTMLInputElement');
if (multiple && !(element as HTMLInputElement).multiple) if (multiple && !(element as HTMLInputElement).multiple)
return 'error:notmultiplefileinput'; throw injected.createStacklessError('Non-multiple file input can only accept single file');
return element; return element;
}, files.length > 1); }, files.length > 1);
if (retargeted === 'error:notconnected') if (retargeted === 'error:notconnected')
return retargeted; return retargeted;
if (!retargeted._objectId) if (!retargeted._objectId)
return throwFatalDOMError(retargeted.rawValue() as FatalDOMError | 'error:notconnected'); return retargeted.rawValue() as 'error:notconnected';
await progress.beforeInputAction(this); await progress.beforeInputAction(this);
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
@ -608,8 +608,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _focus(progress: Progress, resetSelectionIfNotFocused?: boolean): Promise<'error:notconnected' | 'done'> { async _focus(progress: Progress, resetSelectionIfNotFocused?: boolean): Promise<'error:notconnected' | 'done'> {
progress.throwIfAborted(); // Avoid action that has side-effects. progress.throwIfAborted(); // Avoid action that has side-effects.
const result = await this.evaluateInUtility(([injected, node, resetSelectionIfNotFocused]) => injected.focusNode(node, resetSelectionIfNotFocused), resetSelectionIfNotFocused); return await this.evaluateInUtility(([injected, node, resetSelectionIfNotFocused]) => injected.focusNode(node, resetSelectionIfNotFocused), resetSelectionIfNotFocused);
return throwFatalDOMError(result);
} }
async type(metadata: CallMetadata, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<void> { async type(metadata: CallMetadata, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<void> {
@ -673,7 +672,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> { async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
const isChecked = async () => { const isChecked = async () => {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
return throwRetargetableDOMError(throwFatalDOMError(result)); return throwRetargetableDOMError(result);
}; };
if (await isChecked() === state) if (await isChecked() === state)
return 'done'; return 'done';
@ -726,32 +725,32 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'visible'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'visible'), {});
if (result === 'error:notconnected') if (result === 'error:notconnected')
return false; return false;
return throwFatalDOMError(result); return result;
} }
async isHidden(): Promise<boolean> { async isHidden(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'hidden'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'hidden'), {});
return throwRetargetableDOMError(throwFatalDOMError(result)); return throwRetargetableDOMError(result);
} }
async isEnabled(): Promise<boolean> { async isEnabled(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'enabled'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'enabled'), {});
return throwRetargetableDOMError(throwFatalDOMError(result)); return throwRetargetableDOMError(result);
} }
async isDisabled(): Promise<boolean> { async isDisabled(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'disabled'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'disabled'), {});
return throwRetargetableDOMError(throwFatalDOMError(result)); return throwRetargetableDOMError(result);
} }
async isEditable(): Promise<boolean> { async isEditable(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'editable'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'editable'), {});
return throwRetargetableDOMError(throwFatalDOMError(result)); return throwRetargetableDOMError(result);
} }
async isChecked(): Promise<boolean> { async isChecked(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
return throwRetargetableDOMError(throwFatalDOMError(result)); return throwRetargetableDOMError(result);
} }
async waitForElementState(metadata: CallMetadata, state: 'visible' | 'hidden' | 'stable' | 'enabled' | 'disabled' | 'editable', options: types.TimeoutOptions = {}): Promise<void> { async waitForElementState(metadata: CallMetadata, state: 'visible' | 'hidden' | 'stable' | 'enabled' | 'disabled' | 'editable', options: types.TimeoutOptions = {}): Promise<void> {
@ -762,7 +761,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return injected.waitForElementStatesAndPerformAction(node, [state], false, () => 'done' as const); return injected.waitForElementStatesAndPerformAction(node, [state], false, () => 'done' as const);
}, state); }, state);
const pollHandler = new InjectedScriptPollHandler(progress, throwRetargetableDOMError(poll)); const pollHandler = new InjectedScriptPollHandler(progress, throwRetargetableDOMError(poll));
assertDone(throwRetargetableDOMError(throwFatalDOMError(await pollHandler.finish()))); assertDone(throwRetargetableDOMError(await pollHandler.finish()));
}, this._page._timeoutSettings.timeout(options)); }, this._page._timeoutSettings.timeout(options));
} }
@ -814,7 +813,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.log(' element is visible, enabled and stable'); progress.log(' element is visible, enabled and stable');
else else
progress.log(' element is visible and stable'); progress.log(' element is visible and stable');
return throwFatalDOMError(result); return result;
} }
async _checkHitTargetAt(point: types.Point): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> { async _checkHitTargetAt(point: types.Point): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> {
@ -901,33 +900,7 @@ export class InjectedScriptPollHandler<T> {
} }
} }
export function throwFatalDOMError<T>(result: T | FatalDOMError): T { export function throwRetargetableDOMError<T>(result: T | 'error:notconnected'): T {
if (result === 'error:notelement')
throw new Error('Node is not an element');
if (result === 'error:nothtmlelement')
throw new Error('Not an HTMLElement');
if (result === 'error:notfillableelement')
throw new Error('Element is not an <input>, <textarea> or [contenteditable] element');
if (result === 'error:notfillableinputtype')
throw new Error('Input of this type cannot be filled');
if (result === 'error:notfillablenumberinput')
throw new Error('Cannot type text into input[type=number]');
if (result === 'error:notvaliddate')
throw new Error(`Malformed value`);
if (result === 'error:notinput')
throw new Error('Node is not an HTMLInputElement');
if (result === 'error:hasnovalue')
throw new Error('Node is not an HTMLInputElement or HTMLTextAreaElement or HTMLSelectElement');
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');
if (result === 'error:notmultiplefileinput')
throw new Error('Non-multiple file input can only accept single file');
return result;
}
export function throwRetargetableDOMError<T>(result: T | RetargetableDOMError): T {
if (result === 'error:notconnected') if (result === 'error:notconnected')
throw new Error('Element is not attached to the DOM'); throw new Error('Element is not attached to the DOM');
return result; return result;

View File

@ -106,7 +106,7 @@ function checkException(exceptionDetails?: Protocol.Runtime.ExceptionDetails) {
if (exceptionDetails.value) if (exceptionDetails.value)
throw new js.JavaScriptErrorInEvaluate(JSON.stringify(exceptionDetails.value)); throw new js.JavaScriptErrorInEvaluate(JSON.stringify(exceptionDetails.value));
else else
throw new js.JavaScriptErrorInEvaluate(exceptionDetails.text + '\n' + exceptionDetails.stack); throw new js.JavaScriptErrorInEvaluate(exceptionDetails.text + (exceptionDetails.stack ? '\n' + exceptionDetails.stack : ''));
} }
function rewriteError(error: Error): (Protocol.Runtime.evaluateReturnValue | Protocol.Runtime.callFunctionReturnValue) { function rewriteError(error: Error): (Protocol.Runtime.evaluateReturnValue | Protocol.Runtime.callFunctionReturnValue) {

View File

@ -1047,7 +1047,7 @@ export class Frame extends SdkObject {
async innerText(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise<string> { async innerText(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise<string> {
return this._scheduleRerunnableTask(metadata, selector, (progress, element) => { return this._scheduleRerunnableTask(metadata, selector, (progress, element) => {
if (element.namespaceURI !== 'http://www.w3.org/1999/xhtml') if (element.namespaceURI !== 'http://www.w3.org/1999/xhtml')
return 'error:nothtmlelement'; throw progress.injectedScript.createStacklessError('Node is not an HTMLElement');
return (element as HTMLElement).innerText; return (element as HTMLElement).innerText;
}, undefined, options); }, undefined, options);
} }
@ -1063,7 +1063,7 @@ export class Frame extends SdkObject {
async inputValue(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}): Promise<string> { async inputValue(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}): Promise<string> {
return this._scheduleRerunnableTask(metadata, selector, (progress, element) => { return this._scheduleRerunnableTask(metadata, selector, (progress, element) => {
if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT') if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')
return 'error:hasnovalue'; throw progress.injectedScript.createStacklessError('Node is not an <input>, <textarea> or <select> element');
return (element as any).value; return (element as any).value;
}, undefined, options); }, undefined, options);
} }
@ -1073,7 +1073,7 @@ export class Frame extends SdkObject {
const injected = progress.injectedScript; const injected = progress.injectedScript;
return injected.elementState(element, data.state); return injected.elementState(element, data.state);
}, { state }, options); }, { state }, options);
return dom.throwFatalDOMError(dom.throwRetargetableDOMError(result)); return dom.throwRetargetableDOMError(result);
} }
async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}): Promise<boolean> { async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}): Promise<boolean> {
@ -1272,8 +1272,7 @@ export class Frame extends SdkObject {
rerunnableTask.terminate(new Error('Frame got detached.')); rerunnableTask.terminate(new Error('Frame got detached.'));
if (data.context) if (data.context)
rerunnableTask.rerun(data.context); rerunnableTask.rerun(data.context);
const result = await rerunnableTask.promise; return await rerunnableTask.promise;
return dom.throwFatalDOMError(result);
}, this._page._timeoutSettings.timeout(options)); }, this._page._timeoutSettings.timeout(options));
} }

View File

@ -19,7 +19,6 @@ import { XPathEngine } from './xpathSelectorEngine';
import { ReactEngine } from './reactSelectorEngine'; import { ReactEngine } from './reactSelectorEngine';
import { VueEngine } from './vueSelectorEngine'; import { VueEngine } from './vueSelectorEngine';
import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser'; import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser';
import { FatalDOMError } from '../common/domErrors';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator'; import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
import { CSSComplexSelectorList } from '../common/cssParser'; import { CSSComplexSelectorList } from '../common/cssParser';
import { generateSelector } from './selectorGenerator'; import { generateSelector } from './selectorGenerator';
@ -68,8 +67,9 @@ export class InjectedScript {
_evaluator: SelectorEvaluatorImpl; _evaluator: SelectorEvaluatorImpl;
private _stableRafCount: number; private _stableRafCount: number;
private _replaceRafWithTimeout: boolean; private _replaceRafWithTimeout: boolean;
private _browserName: string;
constructor(stableRafCount: number, replaceRafWithTimeout: boolean, customEngines: { name: string, engine: SelectorEngine}[]) { constructor(stableRafCount: number, replaceRafWithTimeout: boolean, browserName: string, customEngines: { name: string, engine: SelectorEngine}[]) {
this._evaluator = new SelectorEvaluatorImpl(new Map()); this._evaluator = new SelectorEvaluatorImpl(new Map());
this._engines = new Map(); this._engines = new Map();
@ -96,6 +96,7 @@ export class InjectedScript {
this._stableRafCount = stableRafCount; this._stableRafCount = stableRafCount;
this._replaceRafWithTimeout = replaceRafWithTimeout; this._replaceRafWithTimeout = replaceRafWithTimeout;
this._browserName = browserName;
} }
eval(expression: string): any { eval(expression: string): any {
@ -396,7 +397,7 @@ export class InjectedScript {
} }
waitForElementStatesAndPerformAction<T>(node: Node, states: ElementState[], force: boolean | undefined, waitForElementStatesAndPerformAction<T>(node: Node, states: ElementState[], force: boolean | undefined,
callback: (node: Node, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected' | FatalDOMError> { callback: (node: Node, progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol): InjectedScriptPoll<T | 'error:notconnected'> {
let lastRect: { x: number, y: number, width: number, height: number } | undefined; let lastRect: { x: number, y: number, width: number, height: number } | undefined;
let counter = 0; let counter = 0;
let samePositionCounter = 0; let samePositionCounter = 0;
@ -461,7 +462,7 @@ export class InjectedScript {
return this.pollRaf(predicate); return this.pollRaf(predicate);
} }
elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | 'error:notcheckbox' { elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' {
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'no-follow-label' : 'follow-label'); const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'no-follow-label' : 'follow-label');
if (!element || !element.isConnected) { if (!element || !element.isConnected) {
if (state === 'hidden') if (state === 'hidden')
@ -488,21 +489,21 @@ export class InjectedScript {
if (['checkbox', 'radio'].includes(element.getAttribute('role') || '')) if (['checkbox', 'radio'].includes(element.getAttribute('role') || ''))
return element.getAttribute('aria-checked') === 'true'; return element.getAttribute('aria-checked') === 'true';
if (element.nodeName !== 'INPUT') if (element.nodeName !== 'INPUT')
return 'error:notcheckbox'; throw this.createStacklessError('Not a checkbox or radio button');
if (!['radio', 'checkbox'].includes((element as HTMLInputElement).type.toLowerCase())) if (!['radio', 'checkbox'].includes((element as HTMLInputElement).type.toLowerCase()))
return 'error:notcheckbox'; throw this.createStacklessError('Not a checkbox or radio button');
return (element as HTMLInputElement).checked; return (element as HTMLInputElement).checked;
} }
throw this.createStacklessError(`Unexpected element state "${state}"`); throw this.createStacklessError(`Unexpected element state "${state}"`);
} }
selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[], selectOptions(optionsToSelect: (Node | { value?: string, label?: string, index?: number })[],
node: Node, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | FatalDOMError | symbol { node: Node, progress: InjectedScriptProgress, continuePolling: symbol): string[] | 'error:notconnected' | symbol {
const element = this.retarget(node, 'follow-label'); const element = this.retarget(node, 'follow-label');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
if (element.nodeName.toLowerCase() !== 'select') if (element.nodeName.toLowerCase() !== 'select')
return 'error:notselect'; throw this.createStacklessError('Element is not a <select> element');
const select = element as HTMLSelectElement; const select = element as HTMLSelectElement;
const options = [...select.options]; const options = [...select.options];
const selectedOptions = []; const selectedOptions = [];
@ -543,7 +544,7 @@ export class InjectedScript {
return selectedOptions.map(option => option.value); return selectedOptions.map(option => option.value);
} }
fill(value: string, node: Node, progress: InjectedScriptProgress): FatalDOMError | 'error:notconnected' | 'needsinput' | 'done' { fill(value: string, node: Node, progress: InjectedScriptProgress): 'error:notconnected' | 'needsinput' | 'done' {
const element = this.retarget(node, 'follow-label'); const element = this.retarget(node, 'follow-label');
if (!element) if (!element)
return 'error:notconnected'; return 'error:notconnected';
@ -554,19 +555,19 @@ export class InjectedScript {
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']); const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
if (!kTextInputTypes.has(type) && !kDateTypes.has(type)) { if (!kTextInputTypes.has(type) && !kDateTypes.has(type)) {
progress.log(` input of type "${type}" cannot be filled`); progress.log(` input of type "${type}" cannot be filled`);
return 'error:notfillableinputtype'; throw this.createStacklessError(`Input of type "${type}" cannot be filled`);
} }
if (type === 'number') { if (type === 'number') {
value = value.trim(); value = value.trim();
if (isNaN(Number(value))) if (isNaN(Number(value)))
return 'error:notfillablenumberinput'; throw this.createStacklessError('Cannot type text into input[type=number]');
} }
if (kDateTypes.has(type)) { if (kDateTypes.has(type)) {
value = value.trim(); value = value.trim();
input.focus(); input.focus();
input.value = value; input.value = value;
if (input.value !== value) if (input.value !== value)
return 'error:notvaliddate'; throw this.createStacklessError('Malformed value');
element.dispatchEvent(new Event('input', { 'bubbles': true })); element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true })); element.dispatchEvent(new Event('change', { 'bubbles': true }));
return 'done'; // We have already changed the value, no need to input it. return 'done'; // We have already changed the value, no need to input it.
@ -574,7 +575,7 @@ export class InjectedScript {
} else if (element.nodeName.toLowerCase() === 'textarea') { } else if (element.nodeName.toLowerCase() === 'textarea') {
// Nothing to check here. // Nothing to check here.
} else if (!(element as HTMLElement).isContentEditable) { } else if (!(element as HTMLElement).isContentEditable) {
return 'error:notfillableelement'; throw this.createStacklessError('Element is not an <input>, <textarea> or [contenteditable] element');
} }
this.selectText(element); this.selectText(element);
return 'needsinput'; // Still need to input the value. return 'needsinput'; // Still need to input the value.
@ -608,11 +609,11 @@ export class InjectedScript {
return 'done'; return 'done';
} }
focusNode(node: Node, resetSelectionIfNotFocused?: boolean): FatalDOMError | 'error:notconnected' | 'done' { focusNode(node: Node, resetSelectionIfNotFocused?: boolean): 'error:notconnected' | 'done' {
if (!node.isConnected) if (!node.isConnected)
return 'error:notconnected'; return 'error:notconnected';
if (node.nodeType !== Node.ELEMENT_NODE) if (node.nodeType !== Node.ELEMENT_NODE)
return 'error:notelement'; throw this.createStacklessError('Node is not an element');
const wasFocused = (node.getRootNode() as (Document | ShadowRoot)).activeElement === node && node.ownerDocument && node.ownerDocument.hasFocus(); const wasFocused = (node.getRootNode() as (Document | ShadowRoot)).activeElement === node && node.ownerDocument && node.ownerDocument.hasFocus();
(node as HTMLElement | SVGElement).focus(); (node as HTMLElement | SVGElement).focus();
@ -762,7 +763,14 @@ export class InjectedScript {
} }
createStacklessError(message: string): Error { createStacklessError(message: string): Error {
if (this._browserName === 'firefox') {
const error = new Error('Error: ' + message);
// Firefox cannot delete the stack, so assign to an empty string.
error.stack = '';
return error;
}
const error = new Error(message); const error = new Error(message);
// Chromium/WebKit should delete the stack instead.
delete error.stack; delete error.stack;
return error; return error;
} }

View File

@ -227,7 +227,7 @@ it.describe('pause', () => {
'page.isChecked(button)- XXms', 'page.isChecked(button)- XXms',
'waiting for selector "button"', 'waiting for selector "button"',
'selector resolved to <button onclick=\"console.log(1)\">Submit</button>', 'selector resolved to <button onclick=\"console.log(1)\">Submit</button>',
'error: Not a checkbox or radio button', 'error: Error: Not a checkbox or radio button',
]); ]);
const error = await scriptPromise; const error = await scriptPromise;
expect(error.message).toContain('Not a checkbox or radio button'); expect(error.message).toContain('Not a checkbox or radio button');

View File

@ -53,9 +53,9 @@ it('inputValue should work', async ({ page, server }) => {
const handle = await page.$('#input'); const handle = await page.$('#input');
expect(await handle.inputValue()).toBe('input value'); expect(await handle.inputValue()).toBe('input value');
expect(await page.inputValue('#inner').catch(e => e.message)).toContain('Node is not an HTMLInputElement or HTMLTextAreaElement or HTMLSelectElement'); expect(await page.inputValue('#inner').catch(e => e.message)).toContain('Node is not an <input>, <textarea> or <select> element');
const handle2 = await page.$('#inner'); const handle2 = await page.$('#inner');
expect(await handle2.inputValue().catch(e => e.message)).toContain('Node is not an HTMLInputElement or HTMLTextAreaElement or HTMLSelectElement'); expect(await handle2.inputValue().catch(e => e.message)).toContain('Node is not an <input>, <textarea> or <select> element');
}); });
it('innerHTML should work', async ({ page, server }) => { it('innerHTML should work', async ({ page, server }) => {
@ -75,10 +75,10 @@ it('innerText should work', async ({ page, server }) => {
it('innerText should throw', async ({ page, server }) => { it('innerText should throw', async ({ page, server }) => {
await page.setContent(`<svg>text</svg>`); await page.setContent(`<svg>text</svg>`);
const error1 = await page.innerText('svg').catch(e => e); const error1 = await page.innerText('svg').catch(e => e);
expect(error1.message).toContain('Not an HTMLElement'); expect(error1.message).toContain('Node is not an HTMLElement');
const handle = await page.$('svg'); const handle = await page.$('svg');
const error2 = await handle.innerText().catch(e => e); const error2 = await handle.innerText().catch(e => e);
expect(error2.message).toContain('Not an HTMLElement'); expect(error2.message).toContain('Node is not an HTMLElement');
}); });
it('textContent should work', async ({ page, server }) => { it('textContent should work', async ({ page, server }) => {

View File

@ -53,9 +53,9 @@ it('inputValue should work', async ({ page, server }) => {
const locator = page.locator('#input'); const locator = page.locator('#input');
expect(await locator.inputValue()).toBe('input value'); expect(await locator.inputValue()).toBe('input value');
expect(await page.inputValue('#inner').catch(e => e.message)).toContain('Node is not an HTMLInputElement or HTMLTextAreaElement or HTMLSelectElement'); expect(await page.inputValue('#inner').catch(e => e.message)).toContain('Node is not an <input>, <textarea> or <select> element');
const locator2 = page.locator('#inner'); const locator2 = page.locator('#inner');
expect(await locator2.inputValue().catch(e => e.message)).toContain('Node is not an HTMLInputElement or HTMLTextAreaElement or HTMLSelectElement'); expect(await locator2.inputValue().catch(e => e.message)).toContain('Node is not an <input>, <textarea> or <select> element');
}); });
it('innerHTML should work', async ({ page, server }) => { it('innerHTML should work', async ({ page, server }) => {
@ -75,10 +75,10 @@ it('innerText should work', async ({ page, server }) => {
it('innerText should throw', async ({ page, server }) => { it('innerText should throw', async ({ page, server }) => {
await page.setContent(`<svg>text</svg>`); await page.setContent(`<svg>text</svg>`);
const error1 = await page.innerText('svg').catch(e => e); const error1 = await page.innerText('svg').catch(e => e);
expect(error1.message).toContain('Not an HTMLElement'); expect(error1.message).toContain('Node is not an HTMLElement');
const locator = page.locator('svg'); const locator = page.locator('svg');
const error2 = await locator.innerText().catch(e => e); const error2 = await locator.innerText().catch(e => e);
expect(error2.message).toContain('Not an HTMLElement'); expect(error2.message).toContain('Node is not an HTMLElement');
}); });
it('innerText should produce log', async ({ page, server }) => { it('innerText should produce log', async ({ page, server }) => {

View File

@ -186,11 +186,11 @@ it('should fill elements with existing value and selection', async ({page, serve
expect(await page.$eval('div[contenteditable]', div => div.textContent)).toBe('replace with this'); expect(await page.$eval('div[contenteditable]', div => div.textContent)).toBe('replace with this');
}); });
it('should throw when element is not an <input>, <textarea> or [contenteditable]', async ({page, server}) => { it('should throw nice error without injected script stack when element is not an <input>', async ({page, server}) => {
let error = null; let error = null;
await page.goto(server.PREFIX + '/input/textarea.html'); await page.goto(server.PREFIX + '/input/textarea.html');
await page.fill('body', '').catch(e => error = e); await page.fill('body', '').catch(e => error = e);
expect(error.message).toContain('Element is not an <input>'); expect(error.message).toContain('page.fill: Error: Element is not an <input>, <textarea> or [contenteditable] element\n=========================== logs');
}); });
it('should throw if passed a non-string value', async ({page, server}) => { it('should throw if passed a non-string value', async ({page, server}) => {

View File

@ -139,7 +139,7 @@ it('should throw when element is not a <select>', async ({page, server}) => {
let error = null; let error = null;
await page.goto(server.PREFIX + '/input/select.html'); await page.goto(server.PREFIX + '/input/select.html');
await page.selectOption('body', '').catch(e => error = e); await page.selectOption('body', '').catch(e => error = e);
expect(error.message).toContain('Element is not a <select> element.'); expect(error.message).toContain('Element is not a <select> element');
}); });
it('should return [] on no matched values', async ({page, server}) => { it('should return [] on no matched values', async ({page, server}) => {