mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-13 17:14:02 +03:00
chore: migrate remaining expect to protocol (#9121)
This commit is contained in:
parent
4eede61f79
commit
1db41c330a
@ -22,6 +22,7 @@ import { monotonicTime } from '../utils/utils';
|
|||||||
import { ElementHandle } from './elementHandle';
|
import { ElementHandle } from './elementHandle';
|
||||||
import { Frame } from './frame';
|
import { Frame } from './frame';
|
||||||
import { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
|
import { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
|
||||||
|
import { parseResult, serializeArgument } from './jsHandle';
|
||||||
|
|
||||||
export class Locator implements api.Locator {
|
export class Locator implements api.Locator {
|
||||||
private _frame: Frame;
|
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 || ''));
|
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 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,6 +228,10 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameInitializer
|
|||||||
}
|
}
|
||||||
|
|
||||||
async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise<channels.FrameExpectResult> {
|
async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise<channels.FrameExpectResult> {
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,7 +77,6 @@ export type ExpectedTextValue = {
|
|||||||
regexFlags?: string,
|
regexFlags?: string,
|
||||||
matchSubstring?: boolean,
|
matchSubstring?: boolean,
|
||||||
normalizeWhiteSpace?: boolean,
|
normalizeWhiteSpace?: boolean,
|
||||||
useInnerText?: boolean,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AXNode = {
|
export type AXNode = {
|
||||||
@ -2185,21 +2184,27 @@ export type FrameWaitForSelectorResult = {
|
|||||||
export type FrameExpectParams = {
|
export type FrameExpectParams = {
|
||||||
selector: string,
|
selector: string,
|
||||||
expression: string,
|
expression: string,
|
||||||
expected?: ExpectedTextValue,
|
expressionArg?: any,
|
||||||
|
expectedText?: ExpectedTextValue[],
|
||||||
|
expectedNumber?: number,
|
||||||
|
expectedValue?: SerializedArgument,
|
||||||
|
useInnerText?: boolean,
|
||||||
isNot?: boolean,
|
isNot?: boolean,
|
||||||
data?: any,
|
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
};
|
};
|
||||||
export type FrameExpectOptions = {
|
export type FrameExpectOptions = {
|
||||||
expected?: ExpectedTextValue,
|
expressionArg?: any,
|
||||||
|
expectedText?: ExpectedTextValue[],
|
||||||
|
expectedNumber?: number,
|
||||||
|
expectedValue?: SerializedArgument,
|
||||||
|
useInnerText?: boolean,
|
||||||
isNot?: boolean,
|
isNot?: boolean,
|
||||||
data?: any,
|
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
};
|
};
|
||||||
export type FrameExpectResult = {
|
export type FrameExpectResult = {
|
||||||
pass: boolean,
|
pass: boolean,
|
||||||
received: string,
|
received?: SerializedValue,
|
||||||
log: string[],
|
log?: string[],
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FrameEvents {
|
export interface FrameEvents {
|
||||||
|
@ -105,7 +105,6 @@ ExpectedTextValue:
|
|||||||
regexFlags: string?
|
regexFlags: string?
|
||||||
matchSubstring: boolean?
|
matchSubstring: boolean?
|
||||||
normalizeWhiteSpace: boolean?
|
normalizeWhiteSpace: boolean?
|
||||||
useInnerText: boolean?
|
|
||||||
|
|
||||||
|
|
||||||
AXNode:
|
AXNode:
|
||||||
@ -1757,14 +1756,21 @@ Frame:
|
|||||||
parameters:
|
parameters:
|
||||||
selector: string
|
selector: string
|
||||||
expression: string
|
expression: string
|
||||||
expected: ExpectedTextValue?
|
expressionArg: json?
|
||||||
|
expectedText:
|
||||||
|
type: array?
|
||||||
|
items: ExpectedTextValue
|
||||||
|
expectedNumber: number?
|
||||||
|
expectedValue: SerializedArgument?
|
||||||
|
useInnerText: boolean?
|
||||||
isNot: boolean?
|
isNot: boolean?
|
||||||
data: json?
|
|
||||||
timeout: number?
|
timeout: number?
|
||||||
returns:
|
returns:
|
||||||
pass: boolean
|
pass: boolean
|
||||||
received: string
|
received: SerializedValue?
|
||||||
log: string[]
|
log:
|
||||||
|
type: array?
|
||||||
|
items: string
|
||||||
tracing:
|
tracing:
|
||||||
snapshot: true
|
snapshot: true
|
||||||
|
|
||||||
|
@ -81,7 +81,6 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
|||||||
regexFlags: tOptional(tString),
|
regexFlags: tOptional(tString),
|
||||||
matchSubstring: tOptional(tBoolean),
|
matchSubstring: tOptional(tBoolean),
|
||||||
normalizeWhiteSpace: tOptional(tBoolean),
|
normalizeWhiteSpace: tOptional(tBoolean),
|
||||||
useInnerText: tOptional(tBoolean),
|
|
||||||
});
|
});
|
||||||
scheme.AXNode = tObject({
|
scheme.AXNode = tObject({
|
||||||
role: tString,
|
role: tString,
|
||||||
@ -887,9 +886,12 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
|
|||||||
scheme.FrameExpectParams = tObject({
|
scheme.FrameExpectParams = tObject({
|
||||||
selector: tString,
|
selector: tString,
|
||||||
expression: 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),
|
isNot: tOptional(tBoolean),
|
||||||
data: tOptional(tAny),
|
|
||||||
timeout: tOptional(tNumber),
|
timeout: tOptional(tNumber),
|
||||||
});
|
});
|
||||||
scheme.WorkerEvaluateExpressionParams = tObject({
|
scheme.WorkerEvaluateExpressionParams = tObject({
|
||||||
|
@ -30,7 +30,8 @@ import { assert, constructURLBasedOnBaseURL, makeWaitForNextTask } from '../util
|
|||||||
import { ManualPromise } from '../utils/async';
|
import { ManualPromise } from '../utils/async';
|
||||||
import { debugLogger } from '../utils/debugLogger';
|
import { debugLogger } from '../utils/debugLogger';
|
||||||
import { CallMetadata, internalCallMetadata, SdkObject } from './instrumentation';
|
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';
|
import { isSessionClosedError } from './common/protocolError';
|
||||||
|
|
||||||
type ContextData = {
|
type ContextData = {
|
||||||
@ -69,7 +70,7 @@ export type NavigationEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SchedulableTask<T> = (injectedScript: js.JSHandle<InjectedScript>) => Promise<js.JSHandle<InjectedScriptPoll<T>>>;
|
export type SchedulableTask<T> = (injectedScript: js.JSHandle<InjectedScript>) => Promise<js.JSHandle<InjectedScriptPoll<T>>>;
|
||||||
export type DomTaskBody<T, R> = (progress: InjectedScriptProgress, element: Element, data: T, continuePolling: any) => R;
|
export type DomTaskBody<T, R> = (progress: InjectedScriptProgress, element: Element, data: T, elements: Element[], continuePolling: any) => R;
|
||||||
|
|
||||||
export class FrameManager {
|
export class FrameManager {
|
||||||
private _page: Page;
|
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);
|
const controller = new ProgressController(metadata, this);
|
||||||
return await this._scheduleRerunnableTaskWithController(controller, selector, (progress, element, data, continuePolling) => {
|
const querySelectorAll = options.expression === 'to.have.count' || options.expression.endsWith('.array');
|
||||||
const injected = progress.injectedScript;
|
const mainWorld = options.expression === 'to.have.property';
|
||||||
const matcher = data.expected ? injected.expectedTextMatcher(data.expected) : null;
|
return await this._scheduleRerunnableTaskWithController(controller, selector, (progress, element, options, elements, continuePolling) => {
|
||||||
let received: string;
|
return progress.injectedScript.expect(progress, element, options, elements, continuePolling);
|
||||||
let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined;
|
}, options, { strict: true, querySelectorAll, mainWorld, logScale: true, ...options }).catch(e => {
|
||||||
|
|
||||||
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 => {
|
|
||||||
if (js.isJavaScriptErrorInEvaluate(e))
|
if (js.isJavaScriptErrorInEvaluate(e))
|
||||||
throw 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);
|
return this._scheduleRerunnableTaskWithController(controller, selector, body, taskData, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _scheduleRerunnableTaskWithController<T, R>(controller: ProgressController, selector: string, body: DomTaskBody<T, R>, taskData: T, options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean } = {}): Promise<R> {
|
private async _scheduleRerunnableTaskWithController<T, R>(
|
||||||
|
controller: ProgressController,
|
||||||
|
selector: string,
|
||||||
|
body: DomTaskBody<T, R>,
|
||||||
|
taskData: T,
|
||||||
|
options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean } & { querySelectorAll?: boolean } & { logScale?: boolean } = {}): Promise<R> {
|
||||||
|
|
||||||
const info = this._page.parseSelector(selector, options);
|
const info = this._page.parseSelector(selector, options);
|
||||||
const callbackText = body.toString();
|
const callbackText = body.toString();
|
||||||
const data = this._contextData.get(options.mainWorld ? 'main' : info.world)!;
|
const data = this._contextData.get(options.mainWorld ? 'main' : info.world)!;
|
||||||
|
|
||||||
return controller.run(async progress => {
|
return controller.run(async progress => {
|
||||||
const rerunnableTask = new RerunnableTask(data, progress, injectedScript => {
|
const rerunnableTask = new RerunnableTask(data, progress, injectedScript => {
|
||||||
return injectedScript.evaluateHandle((injected, { info, taskData, callbackText }) => {
|
return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale }) => {
|
||||||
const callback = injected.eval(callbackText);
|
const callback = injected.eval(callbackText) as DomTaskBody<T, R>;
|
||||||
return injected.pollRaf((progress, continuePolling) => {
|
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);
|
const element = injected.querySelector(info.parsed, document, info.strict);
|
||||||
if (!element)
|
if (!element)
|
||||||
return continuePolling;
|
return continuePolling;
|
||||||
progress.logRepeating(` selector resolved to ${injected.previewNode(element)}`);
|
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);
|
}, true);
|
||||||
|
|
||||||
if (this._detached)
|
if (this._detached)
|
||||||
rerunnableTask.terminate(new Error('waitForFunction failed: 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;
|
const result = await rerunnableTask.promise;
|
||||||
|
@ -23,7 +23,7 @@ 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';
|
||||||
import type { ExpectedTextValue } from '../../protocol/channels';
|
import type * as channels from '../../protocol/channels';
|
||||||
|
|
||||||
type Predicate<T> = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
|
type Predicate<T> = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
|
||||||
|
|
||||||
@ -40,6 +40,8 @@ export type LogEntry = {
|
|||||||
intermediateResult?: string;
|
intermediateResult?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
|
||||||
|
|
||||||
export type InjectedScriptPoll<T> = {
|
export type InjectedScriptPoll<T> = {
|
||||||
run: () => Promise<T>,
|
run: () => Promise<T>,
|
||||||
// Takes more logs, waiting until at least one message is available.
|
// Takes more logs, waiting until at least one message is available.
|
||||||
@ -265,12 +267,26 @@ export class InjectedScript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pollRaf<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
|
pollRaf<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
|
||||||
|
return this.poll(predicate, next => requestAnimationFrame(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
pollInterval<T>(pollInterval: number, predicate: Predicate<T>): InjectedScriptPoll<T> {
|
||||||
|
return this.poll(predicate, next => setTimeout(next, pollInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
pollLogScale<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
|
||||||
|
const pollIntervals = [100, 250, 500];
|
||||||
|
let attempts = 0;
|
||||||
|
return this.poll(predicate, next => setTimeout(next, pollIntervals[attempts++] || 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
poll<T>(predicate: Predicate<T>, scheduleNext: (next: () => void) => void): InjectedScriptPoll<T> {
|
||||||
return this._runAbortableTask(progress => {
|
return this._runAbortableTask(progress => {
|
||||||
let fulfill: (result: T) => void;
|
let fulfill: (result: T) => void;
|
||||||
let reject: (error: Error) => void;
|
let reject: (error: Error) => void;
|
||||||
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
|
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
|
||||||
|
|
||||||
const onRaf = () => {
|
const next = () => {
|
||||||
if (progress.aborted)
|
if (progress.aborted)
|
||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
@ -279,40 +295,14 @@ export class InjectedScript {
|
|||||||
if (success !== continuePolling)
|
if (success !== continuePolling)
|
||||||
fulfill(success as T);
|
fulfill(success as T);
|
||||||
else
|
else
|
||||||
requestAnimationFrame(onRaf);
|
scheduleNext(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
progress.log(' ' + e.message);
|
progress.log(' ' + e.message);
|
||||||
reject(e);
|
reject(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onRaf();
|
next();
|
||||||
return result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pollInterval<T>(pollInterval: number, predicate: Predicate<T>): InjectedScriptPoll<T> {
|
|
||||||
return this._runAbortableTask(progress => {
|
|
||||||
let fulfill: (result: T) => void;
|
|
||||||
let reject: (error: Error) => void;
|
|
||||||
const result = new Promise<T>((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();
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -777,8 +767,127 @@ export class InjectedScript {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedTextMatcher(expected: ExpectedTextValue): ExpectedTextMatcher {
|
expect(progress: InjectedScriptProgress, element: Element, options: FrameExpectParams, elements: Element[], continuePolling: any): { pass: boolean, received?: any } {
|
||||||
return new ExpectedTextMatcher(expected);
|
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 _regex: RegExp | undefined;
|
||||||
private _normalizeWhiteSpace: boolean | undefined;
|
private _normalizeWhiteSpace: boolean | undefined;
|
||||||
|
|
||||||
constructor(expected: ExpectedTextValue) {
|
constructor(expected: channels.ExpectedTextValue) {
|
||||||
this._normalizeWhiteSpace = expected.normalizeWhiteSpace;
|
this._normalizeWhiteSpace = expected.normalizeWhiteSpace;
|
||||||
this._string = expected.matchSubstring ? undefined : this.normalizeWhiteSpace(expected.string);
|
this._string = expected.matchSubstring ? undefined : this.normalizeWhiteSpace(expected.string);
|
||||||
this._substring = expected.matchSubstring ? this.normalizeWhiteSpace(expected.string) : undefined;
|
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;
|
export default InjectedScript;
|
||||||
|
@ -14,198 +14,206 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as channels from '../../protocol/channels';
|
||||||
import { Locator, Page } from '../../..';
|
import { Locator, Page } from '../../..';
|
||||||
import { constructURLBasedOnBaseURL, isString } from '../../utils/utils';
|
import { constructURLBasedOnBaseURL } from '../../utils/utils';
|
||||||
import type { Expect } from '../types';
|
import type { Expect } from '../types';
|
||||||
import { toBeTruthy } from './toBeTruthy';
|
import { toBeTruthy } from './toBeTruthy';
|
||||||
import { toEqual } from './toEqual';
|
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(
|
export function toBeChecked(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBeDisabled(
|
export function toBeDisabled(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBeEditable(
|
export function toBeEditable(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBeEmpty(
|
export function toBeEmpty(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBeEnabled(
|
export function toBeEnabled(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBeFocused(
|
export function toBeFocused(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBeHidden(
|
export function toBeHidden(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toBeVisible(
|
export function toBeVisible(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout) => {
|
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);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toContainText(
|
export function toContainText(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
expected: string,
|
expected: string,
|
||||||
options?: { timeout?: number, useInnerText?: boolean },
|
options?: { timeout?: number, useInnerText?: boolean },
|
||||||
) {
|
) {
|
||||||
return toMatchText.call(this, 'toContainText', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.text', { expected, isNot, timeout });
|
const expectedText = toExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true });
|
||||||
}, expected, { ...options, matchSubstring: true, normalizeWhiteSpace: true, useInnerText: options?.useInnerText });
|
return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
|
||||||
|
}, expected, { ...options, matchSubstring: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveAttribute(
|
export function toHaveAttribute(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
name: string,
|
name: string,
|
||||||
expected: string | RegExp,
|
expected: string | RegExp,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.attribute', { expected, isNot, timeout, data: { name } });
|
const expectedText = toExpectedTextValues([expected]);
|
||||||
|
return await locator._expect('to.have.attribute', { expressionArg: name, expectedText, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveClass(
|
export function toHaveClass(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
expected: string | RegExp | (string | RegExp)[],
|
expected: string | RegExp | (string | RegExp)[],
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
if (Array.isArray(expected)) {
|
if (Array.isArray(expected)) {
|
||||||
return toEqual.call(this, 'toHaveClass', locator, 'Locator', async () => {
|
return toEqual.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await locator.evaluateAll(ee => ee.map(e => e.className));
|
const expectedText = toExpectedTextValues(expected);
|
||||||
|
return await locator._expect('to.have.class.array', { expectedText, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
} else {
|
} else {
|
||||||
return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.class', { expected, isNot, timeout });
|
const expectedText = toExpectedTextValues([expected]);
|
||||||
|
return await locator._expect('to.have.class', { expectedText, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveCount(
|
export function toHaveCount(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
expected: number,
|
expected: number,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toEqual.call(this, 'toHaveCount', locator, 'Locator', async timeout => {
|
return toEqual.call(this, 'toHaveCount', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await locator.count();
|
return await locator._expect('to.have.count', { expectedNumber: expected, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveCSS(
|
export function toHaveCSS(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
name: string,
|
name: string,
|
||||||
expected: string | RegExp,
|
expected: string | RegExp,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.css', { expected, isNot, timeout, data: { name } });
|
const expectedText = toExpectedTextValues([expected]);
|
||||||
|
return await locator._expect('to.have.css', { expressionArg: name, expectedText, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveId(
|
export function toHaveId(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
expected: string | RegExp,
|
expected: string | RegExp,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.id', { expected, isNot, timeout });
|
const expectedText = toExpectedTextValues([expected]);
|
||||||
|
return await locator._expect('to.have.id', { expectedText, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveJSProperty(
|
export function toHaveJSProperty(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
name: string,
|
name: string,
|
||||||
expected: any,
|
expected: any,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async timeout => {
|
return toEqual.call(this, 'toHaveJSProperty', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await locator.evaluate((element, name) => (element as any)[name], name, { timeout });
|
return await locator._expect('to.have.property', { expressionArg: name, expectedValue: expected, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveText(
|
export function toHaveText(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
expected: string | RegExp | (string | RegExp)[],
|
expected: string | RegExp | (string | RegExp)[],
|
||||||
options: { timeout?: number, useInnerText?: boolean } = {},
|
options: { timeout?: number, useInnerText?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
if (Array.isArray(expected)) {
|
if (Array.isArray(expected)) {
|
||||||
const expectedArray = expected.map(e => isString(e) ? normalizeWhiteSpace(e) : e);
|
return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return toEqual.call(this, 'toHaveText', locator, 'Locator', async () => {
|
const expectedText = toExpectedTextValues(expected, { normalizeWhiteSpace: true });
|
||||||
const texts = await locator.evaluateAll((ee, useInnerText) => {
|
return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
|
||||||
return ee.map(e => useInnerText ? (e as HTMLElement).innerText : e.textContent || '');
|
}, expected, options);
|
||||||
}, options?.useInnerText);
|
|
||||||
// Normalize those values that have string expectations.
|
|
||||||
return texts.map((s, index) => isString(expectedArray[index]) ? normalizeWhiteSpace(s) : s);
|
|
||||||
}, expectedArray, options);
|
|
||||||
} else {
|
} else {
|
||||||
return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.text', { expected, isNot, timeout });
|
const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true });
|
||||||
}, expected, { ...options, normalizeWhiteSpace: true, useInnerText: options?.useInnerText });
|
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,
|
expected: string | RegExp,
|
||||||
options: { timeout?: number } = {},
|
options: { timeout?: number } = {},
|
||||||
) {
|
) {
|
||||||
const locator = page.locator(':root');
|
const locator = page.locator(':root') as LocatorEx;
|
||||||
return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveTitle', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.title', { expected, isNot, timeout });
|
const expectedText = toExpectedTextValues([expected], { normalizeWhiteSpace: true });
|
||||||
}, expected, { ...options, normalizeWhiteSpace: true });
|
return await locator._expect('to.have.title', { expectedText, isNot, timeout });
|
||||||
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveURL(
|
export function toHaveURL(
|
||||||
@ -229,19 +238,21 @@ export function toHaveURL(
|
|||||||
) {
|
) {
|
||||||
const baseURL = (page.context() as any)._options.baseURL;
|
const baseURL = (page.context() as any)._options.baseURL;
|
||||||
expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected;
|
expected = typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected;
|
||||||
const locator = page.locator(':root');
|
const locator = page.locator(':root') as LocatorEx;
|
||||||
return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveURL', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.url', { expected, isNot, timeout });
|
const expectedText = toExpectedTextValues([expected]);
|
||||||
|
return await locator._expect('to.have.url', { expectedText, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toHaveValue(
|
export function toHaveValue(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: LocatorEx,
|
||||||
expected: string | RegExp,
|
expected: string | RegExp,
|
||||||
options?: { timeout?: number },
|
options?: { timeout?: number },
|
||||||
) {
|
) {
|
||||||
return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (expected, isNot, timeout) => {
|
return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout) => {
|
||||||
return await (locator as any)._expect('to.have.value', { expected, isNot, timeout });
|
const expectedText = toExpectedTextValues([expected]);
|
||||||
|
return await locator._expect('to.have.value', { expectedText, isNot, timeout });
|
||||||
}, expected, options);
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
@ -14,12 +14,9 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
|
||||||
iterableEquality
|
|
||||||
} from 'expect/build/utils';
|
|
||||||
import { currentTestInfo } from '../globals';
|
import { currentTestInfo } from '../globals';
|
||||||
import type { Expect } from '../types';
|
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.
|
// Omit colon and one or more spaces, so can call getLabelPrinter.
|
||||||
const EXPECTED_LABEL = 'Expected';
|
const EXPECTED_LABEL = 'Expected';
|
||||||
@ -28,19 +25,12 @@ const RECEIVED_LABEL = 'Received';
|
|||||||
// The optional property of matcher context is true if undefined.
|
// The optional property of matcher context is true if undefined.
|
||||||
const isExpand = (expand?: boolean): boolean => expand !== false;
|
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<T>(
|
export async function toEqual<T>(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
matcherName: string,
|
matcherName: string,
|
||||||
receiver: any,
|
receiver: any,
|
||||||
receiverType: string,
|
receiverType: string,
|
||||||
query: (timeout: number) => Promise<T>,
|
query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean, received?: any }>,
|
||||||
expected: T,
|
expected: T,
|
||||||
options: { timeout?: number } = {},
|
options: { timeout?: number } = {},
|
||||||
) {
|
) {
|
||||||
@ -55,14 +45,12 @@ export async function toEqual<T>(
|
|||||||
promise: this.promise,
|
promise: this.promise,
|
||||||
};
|
};
|
||||||
|
|
||||||
let received: T | undefined = undefined;
|
let defaultExpectTimeout = testInfo.project.expect?.timeout;
|
||||||
let pass = false;
|
if (typeof defaultExpectTimeout === 'undefined')
|
||||||
|
defaultExpectTimeout = 5000;
|
||||||
|
const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout;
|
||||||
|
|
||||||
await pollUntilDeadline(testInfo, async remainingTime => {
|
const { pass, received } = await query(this.isNot, timeout);
|
||||||
received = await query(remainingTime);
|
|
||||||
pass = this.equals(received, expected, [iterableEquality, regExpTester]);
|
|
||||||
return pass === !matcherOptions.isNot;
|
|
||||||
}, options.timeout, testInfo._testFinished);
|
|
||||||
|
|
||||||
const message = pass
|
const message = pass
|
||||||
? () =>
|
? () =>
|
||||||
|
@ -29,9 +29,9 @@ export async function toMatchText(
|
|||||||
matcherName: string,
|
matcherName: string,
|
||||||
receiver: any,
|
receiver: any,
|
||||||
receiverType: string,
|
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,
|
expected: string | RegExp,
|
||||||
options: { timeout?: number, matchSubstring?: boolean, normalizeWhiteSpace?: boolean, useInnerText?: boolean } = {},
|
options: { timeout?: number, matchSubstring?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const testInfo = currentTestInfo();
|
const testInfo = currentTestInfo();
|
||||||
if (!testInfo)
|
if (!testInfo)
|
||||||
@ -63,16 +63,7 @@ export async function toMatchText(
|
|||||||
defaultExpectTimeout = 5000;
|
defaultExpectTimeout = 5000;
|
||||||
const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout;
|
const timeout = options.timeout === 0 ? 0 : options.timeout || defaultExpectTimeout;
|
||||||
|
|
||||||
const expectedValue: ExpectedTextValue = {
|
const { pass, received } = await query(this.isNot, timeout);
|
||||||
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 stringSubstring = options.matchSubstring ? 'substring' : 'string';
|
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
|
||||||
const message = pass
|
const message = pass
|
||||||
? () =>
|
? () =>
|
||||||
@ -81,17 +72,17 @@ export async function toMatchText(
|
|||||||
'\n\n' +
|
'\n\n' +
|
||||||
`Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\n` +
|
`Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\n` +
|
||||||
`Received string: ${printReceivedStringContainExpectedSubstring(
|
`Received string: ${printReceivedStringContainExpectedSubstring(
|
||||||
received,
|
received!,
|
||||||
received.indexOf(expected),
|
received!.indexOf(expected),
|
||||||
expected.length,
|
expected.length,
|
||||||
)}`
|
)}`
|
||||||
: this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) +
|
: this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) +
|
||||||
'\n\n' +
|
'\n\n' +
|
||||||
`Expected pattern: not ${this.utils.printExpected(expected)}\n` +
|
`Expected pattern: not ${this.utils.printExpected(expected)}\n` +
|
||||||
`Received string: ${printReceivedStringContainExpectedResult(
|
`Received string: ${printReceivedStringContainExpectedResult(
|
||||||
received,
|
received!,
|
||||||
typeof expected.exec === 'function'
|
typeof expected.exec === 'function'
|
||||||
? expected.exec(received)
|
? expected.exec(received!)
|
||||||
: null,
|
: null,
|
||||||
)}`
|
)}`
|
||||||
: () => {
|
: () => {
|
||||||
@ -117,3 +108,13 @@ export async function toMatchText(
|
|||||||
export function normalizeWhiteSpace(s: string) {
|
export function normalizeWhiteSpace(s: string) {
|
||||||
return s.trim().replace(/\s+/g, ' ');
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
@ -14,58 +14,14 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TestInfoImpl } from './types';
|
|
||||||
import util from 'util';
|
import util from 'util';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
import type { TestError, Location } from './types';
|
import type { TestError, Location } from './types';
|
||||||
import { default as minimatch } from 'minimatch';
|
import { default as minimatch } from 'minimatch';
|
||||||
import { errors } from '../..';
|
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { isRegExp } from '../utils/utils';
|
import { isRegExp } from '../utils/utils';
|
||||||
|
|
||||||
export async function pollUntilDeadline(testInfo: TestInfoImpl, func: (remainingTime: number) => Promise<boolean>, pollTime: number | undefined, deadlinePromise: Promise<void>): Promise<void> {
|
|
||||||
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 {
|
export function serializeError(error: Error | any): TestError {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return {
|
return {
|
||||||
|
Loading…
Reference in New Issue
Block a user