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 {