diff --git a/src/client/locator.ts b/src/client/locator.ts
index ece94e462f..913ea6a496 100644
--- a/src/client/locator.ts
+++ b/src/client/locator.ts
@@ -22,6 +22,7 @@ import { monotonicTime } from '../utils/utils';
import { ElementHandle } from './elementHandle';
import { Frame } from './frame';
import { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
+import { parseResult, serializeArgument } from './jsHandle';
export class Locator implements api.Locator {
private _frame: Frame;
@@ -212,9 +213,15 @@ export class Locator implements api.Locator {
return this._frame.$$eval(this._selector, ee => ee.map(e => e.textContent || ''));
}
- async _expect(expression: string, options: channels.FrameExpectOptions): Promise<{ pass: boolean, received: string }> {
+ async _expect(expression: string, options: channels.FrameExpectOptions): Promise<{ pass: boolean, received?: any, log?: string[] }> {
return this._frame._wrapApiCall(async (channel: channels.FrameChannel) => {
- return (await channel.expect({ selector: this._selector, expression, ...options }));
+ const params: any = { selector: this._selector, expression, ...options };
+ if (options.expectedValue)
+ params.expectedValue = serializeArgument(options.expectedValue);
+ const result = (await channel.expect(params));
+ if (result.received !== undefined)
+ result.received = parseResult(result.received);
+ return result;
});
}
diff --git a/src/dispatchers/frameDispatcher.ts b/src/dispatchers/frameDispatcher.ts
index 061b7f6e58..29ff6be139 100644
--- a/src/dispatchers/frameDispatcher.ts
+++ b/src/dispatchers/frameDispatcher.ts
@@ -228,6 +228,10 @@ export class FrameDispatcher extends Dispatcher {
- return await this._frame.expect(metadata, params.selector, params.expression, params);
+ const expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
+ const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });
+ if (result.received !== undefined)
+ result.received = serializeResult(result.received);
+ return result;
}
}
diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts
index 69b008f74f..211bd624f6 100644
--- a/src/protocol/channels.ts
+++ b/src/protocol/channels.ts
@@ -77,7 +77,6 @@ export type ExpectedTextValue = {
regexFlags?: string,
matchSubstring?: boolean,
normalizeWhiteSpace?: boolean,
- useInnerText?: boolean,
};
export type AXNode = {
@@ -2185,21 +2184,27 @@ export type FrameWaitForSelectorResult = {
export type FrameExpectParams = {
selector: string,
expression: string,
- expected?: ExpectedTextValue,
+ expressionArg?: any,
+ expectedText?: ExpectedTextValue[],
+ expectedNumber?: number,
+ expectedValue?: SerializedArgument,
+ useInnerText?: boolean,
isNot?: boolean,
- data?: any,
timeout?: number,
};
export type FrameExpectOptions = {
- expected?: ExpectedTextValue,
+ expressionArg?: any,
+ expectedText?: ExpectedTextValue[],
+ expectedNumber?: number,
+ expectedValue?: SerializedArgument,
+ useInnerText?: boolean,
isNot?: boolean,
- data?: any,
timeout?: number,
};
export type FrameExpectResult = {
pass: boolean,
- received: string,
- log: string[],
+ received?: SerializedValue,
+ log?: string[],
};
export interface FrameEvents {
diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml
index 7456ad7cea..0eb9599f99 100644
--- a/src/protocol/protocol.yml
+++ b/src/protocol/protocol.yml
@@ -105,7 +105,6 @@ ExpectedTextValue:
regexFlags: string?
matchSubstring: boolean?
normalizeWhiteSpace: boolean?
- useInnerText: boolean?
AXNode:
@@ -1757,14 +1756,21 @@ Frame:
parameters:
selector: string
expression: string
- expected: ExpectedTextValue?
+ expressionArg: json?
+ expectedText:
+ type: array?
+ items: ExpectedTextValue
+ expectedNumber: number?
+ expectedValue: SerializedArgument?
+ useInnerText: boolean?
isNot: boolean?
- data: json?
timeout: number?
returns:
pass: boolean
- received: string
- log: string[]
+ received: SerializedValue?
+ log:
+ type: array?
+ items: string
tracing:
snapshot: true
diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts
index a37a99047a..bfe9385dd3 100644
--- a/src/protocol/validator.ts
+++ b/src/protocol/validator.ts
@@ -81,7 +81,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
regexFlags: tOptional(tString),
matchSubstring: tOptional(tBoolean),
normalizeWhiteSpace: tOptional(tBoolean),
- useInnerText: tOptional(tBoolean),
});
scheme.AXNode = tObject({
role: tString,
@@ -887,9 +886,12 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
scheme.FrameExpectParams = tObject({
selector: tString,
expression: tString,
- expected: tOptional(tType('ExpectedTextValue')),
+ expressionArg: tOptional(tAny),
+ expectedText: tOptional(tArray(tType('ExpectedTextValue'))),
+ expectedNumber: tOptional(tNumber),
+ expectedValue: tOptional(tType('SerializedArgument')),
+ useInnerText: tOptional(tBoolean),
isNot: tOptional(tBoolean),
- data: tOptional(tAny),
timeout: tOptional(tNumber),
});
scheme.WorkerEvaluateExpressionParams = tObject({
diff --git a/src/server/frames.ts b/src/server/frames.ts
index 67ecd9a409..af1607f1d1 100644
--- a/src/server/frames.ts
+++ b/src/server/frames.ts
@@ -30,7 +30,8 @@ import { assert, constructURLBasedOnBaseURL, makeWaitForNextTask } from '../util
import { ManualPromise } from '../utils/async';
import { debugLogger } from '../utils/debugLogger';
import { CallMetadata, internalCallMetadata, SdkObject } from './instrumentation';
-import InjectedScript, { ElementStateWithoutStable, InjectedScriptPoll, InjectedScriptProgress } from './injected/injectedScript';
+import type InjectedScript from './injected/injectedScript';
+import type { ElementStateWithoutStable, FrameExpectParams, InjectedScriptPoll, InjectedScriptProgress } from './injected/injectedScript';
import { isSessionClosedError } from './common/protocolError';
type ContextData = {
@@ -69,7 +70,7 @@ export type NavigationEvent = {
};
export type SchedulableTask = (injectedScript: js.JSHandle) => Promise>>;
-export type DomTaskBody = (progress: InjectedScriptProgress, element: Element, data: T, continuePolling: any) => R;
+export type DomTaskBody = (progress: InjectedScriptProgress, element: Element, data: T, elements: Element[], continuePolling: any) => R;
export class FrameManager {
private _page: Page;
@@ -1160,78 +1161,16 @@ export class Frame extends SdkObject {
});
}
- async expect(metadata: CallMetadata, selector: string, expression: string, options: channels.FrameExpectParams): Promise<{ pass: boolean, received: string, log: string[] }> {
+ async expect(metadata: CallMetadata, selector: string, options: FrameExpectParams): Promise<{ pass: boolean, received?: any, log?: string[] }> {
const controller = new ProgressController(metadata, this);
- return await this._scheduleRerunnableTaskWithController(controller, selector, (progress, element, data, continuePolling) => {
- const injected = progress.injectedScript;
- const matcher = data.expected ? injected.expectedTextMatcher(data.expected) : null;
- let received: string;
- let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined;
-
- if (data.expression === 'to.be.checked') {
- elementState = progress.injectedScript.elementState(element, 'checked');
- } else if (data.expression === 'to.be.disabled') {
- elementState = progress.injectedScript.elementState(element, 'disabled');
- } else if (data.expression === 'to.be.editable') {
- elementState = progress.injectedScript.elementState(element, 'editable');
- } else if (data.expression === 'to.be.empty') {
- if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
- elementState = !(element as HTMLInputElement).value;
- else
- elementState = !element.textContent?.trim();
- } else if (data.expression === 'to.be.enabled') {
- elementState = progress.injectedScript.elementState(element, 'enabled');
- } else if (data.expression === 'to.be.focused') {
- elementState = document.activeElement === element;
- } else if (data.expression === 'to.be.hidden') {
- elementState = progress.injectedScript.elementState(element, 'hidden');
- } else if (data.expression === 'to.be.visible') {
- elementState = progress.injectedScript.elementState(element, 'visible');
- }
-
- if (elementState !== undefined) {
- if (elementState === 'error:notcheckbox')
- throw injected.createStacklessError('Element is not a checkbox');
- if (elementState === 'error:notconnected')
- throw injected.createStacklessError('Element is not connected');
- if (elementState === data.isNot)
- return continuePolling;
- return { pass: !data.isNot };
- }
-
- if (data.expression === 'to.have.attribute') {
- received = element.getAttribute(data.data.name) || '';
- } else if (data.expression === 'to.have.class') {
- received = element.className;
- } else if (data.expression === 'to.have.css') {
- received = (window.getComputedStyle(element) as any)[data.data.name];
- } else if (data.expression === 'to.have.id') {
- received = element.id;
- } else if (data.expression === 'to.have.text') {
- received = data.expected!.useInnerText ? (element as HTMLElement).innerText : element.textContent || '';
- } else if (data.expression === 'to.have.title') {
- received = document.title;
- } else if (data.expression === 'to.have.url') {
- received = document.location.href;
- } else if (data.expression === 'to.have.value') {
- if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT') {
- progress.log('Not an input element');
- return 'error:hasnovalue';
- }
- received = (element as any).value;
- } else {
- throw new Error(`Internal error, unknown matcher ${data.expression}`);
- }
-
- progress.setIntermediateResult(received);
-
- if (matcher && matcher.matches(received) === data.isNot)
- return continuePolling;
- return { received, pass: !data.isNot } as any;
- }, { expression, expected: options.expected, isNot: options.isNot, data: options.data }, { strict: true, ...options }).catch(e => {
+ const querySelectorAll = options.expression === 'to.have.count' || options.expression.endsWith('.array');
+ const mainWorld = options.expression === 'to.have.property';
+ return await this._scheduleRerunnableTaskWithController(controller, selector, (progress, element, options, elements, continuePolling) => {
+ return progress.injectedScript.expect(progress, element, options, elements, continuePolling);
+ }, options, { strict: true, querySelectorAll, mainWorld, logScale: true, ...options }).catch(e => {
if (js.isJavaScriptErrorInEvaluate(e))
throw e;
- return { received: controller.lastIntermediateResult(), pass: options.isNot, log: metadata.log };
+ return { received: controller.lastIntermediateResult(), pass: !!options.isNot, log: metadata.log };
});
}
@@ -1297,27 +1236,39 @@ export class Frame extends SdkObject {
return this._scheduleRerunnableTaskWithController(controller, selector, body, taskData, options);
}
- private async _scheduleRerunnableTaskWithController(controller: ProgressController, selector: string, body: DomTaskBody, taskData: T, options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean } = {}): Promise {
+ private async _scheduleRerunnableTaskWithController(
+ controller: ProgressController,
+ selector: string,
+ body: DomTaskBody,
+ taskData: T,
+ options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean } & { querySelectorAll?: boolean } & { logScale?: boolean } = {}): Promise {
+
const info = this._page.parseSelector(selector, options);
const callbackText = body.toString();
const data = this._contextData.get(options.mainWorld ? 'main' : info.world)!;
return controller.run(async progress => {
const rerunnableTask = new RerunnableTask(data, progress, injectedScript => {
- return injectedScript.evaluateHandle((injected, { info, taskData, callbackText }) => {
- const callback = injected.eval(callbackText);
- return injected.pollRaf((progress, continuePolling) => {
+ return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale }) => {
+ const callback = injected.eval(callbackText) as DomTaskBody;
+ const poller = logScale ? injected.pollLogScale.bind(injected) : injected.pollRaf.bind(injected);
+ return poller((progress, continuePolling) => {
+ if (querySelectorAll) {
+ const elements = injected.querySelectorAll(info.parsed, document);
+ return callback(progress, elements[0], taskData as T, elements, continuePolling);
+ }
+
const element = injected.querySelector(info.parsed, document, info.strict);
if (!element)
return continuePolling;
progress.logRepeating(` selector resolved to ${injected.previewNode(element)}`);
- return callback(progress, element, taskData, continuePolling);
+ return callback(progress, element, taskData as T, [], continuePolling);
});
- }, { info, taskData, callbackText });
+ }, { info, taskData, callbackText, querySelectorAll: options.querySelectorAll, logScale: options.logScale });
}, true);
if (this._detached)
- rerunnableTask.terminate(new Error('waitForFunction failed: frame got detached.'));
+ rerunnableTask.terminate(new Error('Frame got detached.'));
if (data.context)
rerunnableTask.rerun(data.context);
const result = await rerunnableTask.promise;
diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts
index 435bbe26de..8341875f8c 100644
--- a/src/server/injected/injectedScript.ts
+++ b/src/server/injected/injectedScript.ts
@@ -23,7 +23,7 @@ import { FatalDOMError } from '../common/domErrors';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
import { CSSComplexSelectorList } from '../common/cssParser';
import { generateSelector } from './selectorGenerator';
-import type { ExpectedTextValue } from '../../protocol/channels';
+import type * as channels from '../../protocol/channels';
type Predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
@@ -40,6 +40,8 @@ export type LogEntry = {
intermediateResult?: string;
};
+export type FrameExpectParams = Omit & { expectedValue?: any };
+
export type InjectedScriptPoll = {
run: () => Promise,
// Takes more logs, waiting until at least one message is available.
@@ -265,12 +267,26 @@ export class InjectedScript {
}
pollRaf(predicate: Predicate): InjectedScriptPoll {
+ return this.poll(predicate, next => requestAnimationFrame(next));
+ }
+
+ pollInterval(pollInterval: number, predicate: Predicate): InjectedScriptPoll {
+ return this.poll(predicate, next => setTimeout(next, pollInterval));
+ }
+
+ pollLogScale(predicate: Predicate): InjectedScriptPoll {
+ const pollIntervals = [100, 250, 500];
+ let attempts = 0;
+ return this.poll(predicate, next => setTimeout(next, pollIntervals[attempts++] || 1000));
+ }
+
+ poll(predicate: Predicate, scheduleNext: (next: () => void) => void): InjectedScriptPoll {
return this._runAbortableTask(progress => {
let fulfill: (result: T) => void;
let reject: (error: Error) => void;
const result = new Promise((f, r) => { fulfill = f; reject = r; });
- const onRaf = () => {
+ const next = () => {
if (progress.aborted)
return;
try {
@@ -279,40 +295,14 @@ export class InjectedScript {
if (success !== continuePolling)
fulfill(success as T);
else
- requestAnimationFrame(onRaf);
+ scheduleNext(next);
} catch (e) {
progress.log(' ' + e.message);
reject(e);
}
};
- onRaf();
- return result;
- });
- }
-
- pollInterval(pollInterval: number, predicate: Predicate): InjectedScriptPoll {
- return this._runAbortableTask(progress => {
- let fulfill: (result: T) => void;
- let reject: (error: Error) => void;
- const result = new Promise((f, r) => { fulfill = f; reject = r; });
-
- const onTimeout = () => {
- if (progress.aborted)
- return;
- try {
- const continuePolling = Symbol('continuePolling');
- const success = predicate(progress, continuePolling);
- if (success !== continuePolling)
- fulfill(success as T);
- else
- setTimeout(onTimeout, pollInterval);
- } catch (e) {
- reject(e);
- }
- };
-
- onTimeout();
+ next();
return result;
});
}
@@ -777,8 +767,127 @@ export class InjectedScript {
return error;
}
- expectedTextMatcher(expected: ExpectedTextValue): ExpectedTextMatcher {
- return new ExpectedTextMatcher(expected);
+ expect(progress: InjectedScriptProgress, element: Element, options: FrameExpectParams, elements: Element[], continuePolling: any): { pass: boolean, received?: any } {
+ const injected = progress.injectedScript;
+ const expression = options.expression;
+
+ {
+ // Element state / boolean values.
+ let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined;
+ if (expression === 'to.be.checked') {
+ elementState = progress.injectedScript.elementState(element, 'checked');
+ } else if (expression === 'to.be.disabled') {
+ elementState = progress.injectedScript.elementState(element, 'disabled');
+ } else if (expression === 'to.be.editable') {
+ elementState = progress.injectedScript.elementState(element, 'editable');
+ } else if (expression === 'to.be.empty') {
+ if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
+ elementState = !(element as HTMLInputElement).value;
+ else
+ elementState = !element.textContent?.trim();
+ } else if (expression === 'to.be.enabled') {
+ elementState = progress.injectedScript.elementState(element, 'enabled');
+ } else if (expression === 'to.be.focused') {
+ elementState = document.activeElement === element;
+ } else if (expression === 'to.be.hidden') {
+ elementState = progress.injectedScript.elementState(element, 'hidden');
+ } else if (expression === 'to.be.visible') {
+ elementState = progress.injectedScript.elementState(element, 'visible');
+ }
+
+ if (elementState !== undefined) {
+ if (elementState === 'error:notcheckbox')
+ throw injected.createStacklessError('Element is not a checkbox');
+ if (elementState === 'error:notconnected')
+ throw injected.createStacklessError('Element is not connected');
+ if (elementState === options.isNot)
+ return continuePolling;
+ return { pass: !options.isNot };
+ }
+ }
+
+ {
+ // Single number value.
+ if (expression === 'to.have.count') {
+ const received = elements.length;
+ const matches = received === options.expectedNumber;
+ if (matches === options.isNot)
+ return continuePolling;
+ return { pass: !options.isNot, received };
+ }
+ }
+
+ {
+ // JS property
+ if (expression === 'to.have.property') {
+ const received = (element as any)[options.expressionArg];
+ const matches = deepEquals(received, options.expectedValue);
+ if (matches === options.isNot) {
+ progress.setIntermediateResult(received);
+ return continuePolling;
+ }
+ return { received, pass: !options.isNot };
+ }
+ }
+
+ {
+ // Single text value.
+ let received: string | undefined;
+ if (expression === 'to.have.attribute') {
+ received = element.getAttribute(options.expressionArg) || '';
+ } else if (expression === 'to.have.class') {
+ received = element.className;
+ } else if (expression === 'to.have.css') {
+ received = (window.getComputedStyle(element) as any)[options.expressionArg];
+ } else if (expression === 'to.have.id') {
+ received = element.id;
+ } else if (expression === 'to.have.text') {
+ received = options.useInnerText ? (element as HTMLElement).innerText : element.textContent || '';
+ } else if (expression === 'to.have.title') {
+ received = document.title;
+ } else if (expression === 'to.have.url') {
+ received = document.location.href;
+ } else if (expression === 'to.have.value') {
+ if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT')
+ throw this.createStacklessError('Not an input element');
+ received = (element as any).value;
+ }
+
+ if (received !== undefined && options.expectedText) {
+ const matcher = new ExpectedTextMatcher(options.expectedText[0]);
+ if (matcher.matches(received) === options.isNot) {
+ progress.setIntermediateResult(received);
+ return continuePolling;
+ }
+ return { received, pass: !options.isNot };
+ }
+ }
+
+ {
+ // List of values.
+ let received: string[] | undefined;
+ if (expression === 'to.have.text.array')
+ received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : e.textContent || '');
+ else if (expression === 'to.have.class.array')
+ received = elements.map(e => e.className);
+
+ if (received && options.expectedText) {
+ if (received.length !== options.expectedText.length) {
+ progress.setIntermediateResult(received);
+ return continuePolling;
+ }
+
+ const matchers = options.expectedText.map(e => new ExpectedTextMatcher(e));
+ for (let i = 0; i < received.length; ++i) {
+ if (matchers[i].matches(received[i]) === options.isNot) {
+ progress.setIntermediateResult(received);
+ return continuePolling;
+ }
+ }
+ return { received, pass: !options.isNot };
+ }
+ }
+ throw this.createStacklessError('Unknown expect matcher: ' + options.expression);
}
}
@@ -875,7 +984,7 @@ class ExpectedTextMatcher {
private _regex: RegExp | undefined;
private _normalizeWhiteSpace: boolean | undefined;
- constructor(expected: ExpectedTextValue) {
+ constructor(expected: channels.ExpectedTextValue) {
this._normalizeWhiteSpace = expected.normalizeWhiteSpace;
this._string = expected.matchSubstring ? undefined : this.normalizeWhiteSpace(expected.string);
this._substring = expected.matchSubstring ? this.normalizeWhiteSpace(expected.string) : undefined;
@@ -901,4 +1010,51 @@ class ExpectedTextMatcher {
}
}
+function deepEquals(a: any, b: any): boolean {
+ if (a === b)
+ return true;
+
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
+ if (a.constructor !== b.constructor)
+ return false;
+
+ if (Array.isArray(a)) {
+ if (a.length !== b.length)
+ return false;
+ for (let i = 0; i < a.length; ++i) {
+ if (!deepEquals(a[i], b[i]))
+ return false;
+ }
+ return true;
+ }
+
+ if (a instanceof RegExp)
+ return a.source === b.source && a.flags === b.flags;
+ // This covers Date.
+ if (a.valueOf !== Object.prototype.valueOf)
+ return a.valueOf() === b.valueOf();
+ // This covers custom objects.
+ if (a.toString !== Object.prototype.toString)
+ return a.toString() === b.toString();
+
+ const keys = Object.keys(a);
+ if (keys.length !== Object.keys(b).length)
+ return false;
+
+ for (let i = 0; i < keys.length; ++i) {
+ if (!b.hasOwnProperty(keys[i]))
+ return false;
+ }
+
+ for (const key of keys) {
+ if (!deepEquals(a[key], b[key]))
+ return false;
+ }
+ return true;
+ }
+
+ // NaN
+ return isNaN(a) === isNaN(b);
+}
+
export default InjectedScript;
diff --git a/src/test/matchers/matchers.ts b/src/test/matchers/matchers.ts
index 7eeb14c3fe..51d452ccd1 100644
--- a/src/test/matchers/matchers.ts
+++ b/src/test/matchers/matchers.ts
@@ -14,198 +14,206 @@
* limitations under the License.
*/
+import * as channels from '../../protocol/channels';
import { Locator, Page } from '../../..';
-import { constructURLBasedOnBaseURL, isString } from '../../utils/utils';
+import { constructURLBasedOnBaseURL } from '../../utils/utils';
import type { Expect } from '../types';
import { toBeTruthy } from './toBeTruthy';
import { toEqual } from './toEqual';
-import { normalizeWhiteSpace, toMatchText } from './toMatchText';
+import { toExpectedTextValues, toMatchText } from './toMatchText';
+
+interface LocatorEx extends Locator {
+ _expect(expression: string, options: channels.FrameExpectOptions): Promise<{ pass: boolean, received?: string, log?: string[] }>;
+}
export function toBeChecked(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.checked', { isNot, timeout });
+ return await locator._expect('to.be.checked', { isNot, timeout });
}, options);
}
export function toBeDisabled(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.disabled', { isNot, timeout });
+ return await locator._expect('to.be.disabled', { isNot, timeout });
}, options);
}
export function toBeEditable(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.editable', { isNot, timeout });
+ return await locator._expect('to.be.editable', { isNot, timeout });
}, options);
}
export function toBeEmpty(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.empty', { isNot, timeout });
+ return await locator._expect('to.be.empty', { isNot, timeout });
}, options);
}
export function toBeEnabled(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.enabled', { isNot, timeout });
+ return await locator._expect('to.be.enabled', { isNot, timeout });
}, options);
}
export function toBeFocused(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.focused', { isNot, timeout });
+ return await locator._expect('to.be.focused', { isNot, timeout });
}, options);
}
export function toBeHidden(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.hidden', { isNot, timeout });
+ return await locator._expect('to.be.hidden', { isNot, timeout });
}, options);
}
export function toBeVisible(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout) => {
- return await (locator as any)._expect('to.be.visible', { isNot, timeout });
+ return await locator._expect('to.be.visible', { isNot, timeout });
}, options);
}
export function toContainText(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
expected: string,
options?: { timeout?: number, useInnerText?: boolean },
) {
- return toMatchText.call(this, 'toContainText', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.text', { expected, isNot, timeout });
- }, expected, { ...options, matchSubstring: true, normalizeWhiteSpace: true, useInnerText: options?.useInnerText });
+ return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true });
+ return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
+ }, expected, { ...options, matchSubstring: true });
}
export function toHaveAttribute(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
name: string,
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.attribute', { expected, isNot, timeout, data: { name } });
+ return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected]);
+ return await locator._expect('to.have.attribute', { expressionArg: name, expectedText, isNot, timeout });
}, expected, options);
}
export function toHaveClass(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
expected: string | RegExp | (string | RegExp)[],
options?: { timeout?: number },
) {
if (Array.isArray(expected)) {
- return toEqual.call(this, 'toHaveClass', locator, 'Locator', async () => {
- return await locator.evaluateAll(ee => ee.map(e => e.className));
+ return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues(expected);
+ return await locator._expect('to.have.class.array', { expectedText, isNot, timeout });
}, expected, options);
} else {
- return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.class', { expected, isNot, timeout });
+ return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected]);
+ return await locator._expect('to.have.class', { expectedText, isNot, timeout });
}, expected, options);
}
}
export function toHaveCount(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
expected: number,
options?: { timeout?: number },
) {
- return toEqual.call(this, 'toHaveCount', locator, 'Locator', async timeout => {
- return await locator.count();
+ return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout) => {
+ return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout });
}, expected, options);
}
export function toHaveCSS(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
name: string,
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.css', { expected, isNot, timeout, data: { name } });
+ return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected]);
+ return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, timeout });
}, expected, options);
}
export function toHaveId(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.id', { expected, isNot, timeout });
+ return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected]);
+ return await locator._expect('to.have.id', { expectedText, isNot, timeout });
}, expected, options);
}
export function toHaveJSProperty(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
name: string,
expected: any,
options?: { timeout?: number },
) {
- return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async timeout => {
- return await locator.evaluate((element, name) => (element as any)[name], name, { timeout });
+ return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout) => {
+ return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout });
}, expected, options);
}
export function toHaveText(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
expected: string | RegExp | (string | RegExp)[],
options: { timeout?: number, useInnerText?: boolean } = {},
) {
if (Array.isArray(expected)) {
- const expectedArray = expected.map(e => isString(e) ? normalizeWhiteSpace(e) : e);
- return toEqual.call(this, 'toHaveText', locator, 'Locator', async () => {
- const texts = await locator.evaluateAll((ee, useInnerText) => {
- return ee.map(e => useInnerText ? (e as HTMLElement).innerText : e.textContent || '');
- }, options?.useInnerText);
- // Normalize those values that have string expectations.
- return texts.map((s, index) => isString(expectedArray[index]) ? normalizeWhiteSpace(s) : s);
- }, expectedArray, options);
+ return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues(expected, { normalizeWhiteSpace: true });
+ return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
+ }, expected, options);
} else {
- return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.text', { expected, isNot, timeout });
- }, expected, { ...options, normalizeWhiteSpace: true, useInnerText: options?.useInnerText });
+ return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true });
+ return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
+ }, expected, options);
}
}
@@ -215,10 +223,11 @@ export function toHaveTitle(
expected: string | RegExp,
options: { timeout?: number } = {},
) {
- const locator = page.locator(':root');
- return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.title', { expected, isNot, timeout });
- }, expected, { ...options, normalizeWhiteSpace: true });
+ const locator = page.locator(':root') as LocatorEx;
+ return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true });
+ return await locator._expect('to.have.title', { expectedText, isNot, timeout });
+ }, expected, options);
}
export function toHaveURL(
@@ -229,19 +238,21 @@ export function toHaveURL(
) {
const baseURL = (page.context() as any)._options.baseURL;
expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected;
- const locator = page.locator(':root');
- return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.url', { expected, isNot, timeout });
+ const locator = page.locator(':root') as LocatorEx;
+ return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected]);
+ return await locator._expect('to.have.url', { expectedText, isNot, timeout });
}, expected, options);
}
export function toHaveValue(
this: ReturnType,
- locator: Locator,
+ locator: LocatorEx,
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (expected, isNot, timeout) => {
- return await (locator as any)._expect('to.have.value', { expected, isNot, timeout });
+ return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout) => {
+ const expectedText = toExpectedTextValues([expected]);
+ return await locator._expect('to.have.value', { expectedText, isNot, timeout });
}, expected, options);
}
diff --git a/src/test/matchers/toEqual.ts b/src/test/matchers/toEqual.ts
index 88deead571..9e49d4934e 100644
--- a/src/test/matchers/toEqual.ts
+++ b/src/test/matchers/toEqual.ts
@@ -14,12 +14,9 @@
* limitations under the License.
*/
-import {
- iterableEquality
-} from 'expect/build/utils';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
-import { expectType, pollUntilDeadline } from '../util';
+import { expectType } from '../util';
// Omit colon and one or more spaces, so can call getLabelPrinter.
const EXPECTED_LABEL = 'Expected';
@@ -28,19 +25,12 @@ const RECEIVED_LABEL = 'Received';
// The optional property of matcher context is true if undefined.
const isExpand = (expand?: boolean): boolean => expand !== false;
-function regExpTester(a: any, b: any): boolean | undefined {
- if (typeof a === 'string' && b instanceof RegExp) {
- b.lastIndex = 0;
- return b.test(a);
- }
-}
-
export async function toEqual(
this: ReturnType,
matcherName: string,
receiver: any,
receiverType: string,
- query: (timeout: number) => Promise,
+ query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean, received?: any }>,
expected: T,
options: { timeout?: number } = {},
) {
@@ -55,14 +45,12 @@ export async function toEqual(
promise: this.promise,
};
- let received: T | undefined = undefined;
- let pass = false;
+ let defaultExpectTimeout = testInfo.project.expect?.timeout;
+ if (typeof defaultExpectTimeout === 'undefined')
+ defaultExpectTimeout = 5000;
+ const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout;
- await pollUntilDeadline(testInfo, async remainingTime => {
- received = await query(remainingTime);
- pass = this.equals(received, expected, [iterableEquality, regExpTester]);
- return pass === !matcherOptions.isNot;
- }, options.timeout, testInfo._testFinished);
+ const { pass, received } = await query(this.isNot, timeout);
const message = pass
? () =>
diff --git a/src/test/matchers/toMatchText.ts b/src/test/matchers/toMatchText.ts
index 119156064c..0d1738ee8e 100644
--- a/src/test/matchers/toMatchText.ts
+++ b/src/test/matchers/toMatchText.ts
@@ -29,9 +29,9 @@ export async function toMatchText(
matcherName: string,
receiver: any,
receiverType: string,
- query: (expected: ExpectedTextValue, isNot: boolean, timeout: number) => Promise<{ pass: boolean, received: string }>,
+ query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean, received?: string }>,
expected: string | RegExp,
- options: { timeout?: number, matchSubstring?: boolean, normalizeWhiteSpace?: boolean, useInnerText?: boolean } = {},
+ options: { timeout?: number, matchSubstring?: boolean } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
@@ -63,16 +63,7 @@ export async function toMatchText(
defaultExpectTimeout = 5000;
const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout;
- const expectedValue: ExpectedTextValue = {
- string: isString(expected) ? expected : undefined,
- regexSource: isRegExp(expected) ? expected.source : undefined,
- regexFlags: isRegExp(expected) ? expected.flags : undefined,
- matchSubstring: options.matchSubstring,
- normalizeWhiteSpace: options.normalizeWhiteSpace,
- useInnerText: options.useInnerText,
- };
-
- const { pass, received } = await query(expectedValue, this.isNot, timeout);
+ const { pass, received } = await query(this.isNot, timeout);
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
const message = pass
? () =>
@@ -81,17 +72,17 @@ export async function toMatchText(
'\n\n' +
`Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\n` +
`Received string: ${printReceivedStringContainExpectedSubstring(
- received,
- received.indexOf(expected),
+ received!,
+ received!.indexOf(expected),
expected.length,
)}`
: this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
`Expected pattern: not ${this.utils.printExpected(expected)}\n` +
`Received string: ${printReceivedStringContainExpectedResult(
- received,
+ received!,
typeof expected.exec === 'function'
- ? expected.exec(received)
+ ? expected.exec(received!)
: null,
)}`
: () => {
@@ -117,3 +108,13 @@ export async function toMatchText(
export function normalizeWhiteSpace(s: string) {
return s.trim().replace(/\s+/g, ' ');
}
+
+export function toExpectedTextValues(items: (string | RegExp)[], options: { matchSubstring?: boolean, normalizeWhiteSpace?: boolean } = {}): ExpectedTextValue[] {
+ return items.map(i => ({
+ string: isString(i) ? i : undefined,
+ regexSource: isRegExp(i) ? i.source : undefined,
+ regexFlags: isRegExp(i) ? i.flags : undefined,
+ matchSubstring: options.matchSubstring,
+ normalizeWhiteSpace: options.normalizeWhiteSpace,
+ }));
+}
diff --git a/src/test/util.ts b/src/test/util.ts
index 506d69c57a..3977a76477 100644
--- a/src/test/util.ts
+++ b/src/test/util.ts
@@ -14,58 +14,14 @@
* limitations under the License.
*/
-import type { TestInfoImpl } from './types';
import util from 'util';
import path from 'path';
import url from 'url';
import type { TestError, Location } from './types';
import { default as minimatch } from 'minimatch';
-import { errors } from '../..';
import debug from 'debug';
import { isRegExp } from '../utils/utils';
-export async function pollUntilDeadline(testInfo: TestInfoImpl, func: (remainingTime: number) => Promise, pollTime: number | undefined, deadlinePromise: Promise): Promise {
- let defaultExpectTimeout = testInfo.project.expect?.timeout;
- if (typeof defaultExpectTimeout === 'undefined')
- defaultExpectTimeout = 5000;
- pollTime = pollTime === 0 ? 0 : pollTime || defaultExpectTimeout;
- const deadline = pollTime ? monotonicTime() + pollTime : 0;
-
- let aborted = false;
- const abortedPromise = deadlinePromise.then(() => {
- aborted = true;
- return true;
- });
-
- const pollIntervals = [100, 250, 500];
- let attempts = 0;
- while (!aborted) {
- const remainingTime = deadline ? deadline - monotonicTime() : 1000 * 3600 * 24;
- if (remainingTime <= 0)
- break;
-
- try {
- // Either aborted, or func() returned truthy.
- const result = await Promise.race([
- func(remainingTime),
- abortedPromise,
- ]);
- if (result)
- return;
- } catch (e) {
- if (e instanceof errors.TimeoutError)
- return;
- throw e;
- }
-
- let timer: NodeJS.Timer;
- const timeoutPromise = new Promise(f => timer = setTimeout(f, pollIntervals[attempts++] || 1000));
- await Promise.race([abortedPromise, timeoutPromise]);
- clearTimeout(timer!);
- }
-}
-
-
export function serializeError(error: Error | any): TestError {
if (error instanceof Error) {
return {