Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ 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 }> {
return this._frame._wrapApiCall(async (channel: channels.FrameChannel) => {
return (await channel.expect({ selector: this._selector, expression, ...options }));
});
}

[(util.inspect as any).custom]() {
return this.toString();
}
Expand Down
4 changes: 4 additions & 0 deletions src/dispatchers/frameDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,8 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameInitializer
async title(params: channels.FrameTitleParams, metadata: CallMetadata): Promise<channels.FrameTitleResult> {
return { value: await this._frame.title() };
}

async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise<channels.FrameExpectResult> {
return await this._frame.expect(metadata, params.selector, params.expression, params);
}
}
30 changes: 30 additions & 0 deletions src/protocol/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export type SerializedArgument = {
handles: Channel[],
};

export type ExpectedTextValue = {
string?: string,
regexSource?: string,
regexFlags?: string,
matchSubstring?: boolean,
normalizeWhiteSpace?: boolean,
useInnerText?: boolean,
};

export type AXNode = {
role: string,
name: string,
Expand Down Expand Up @@ -1609,6 +1618,7 @@ export interface FrameChannel extends Channel {
waitForTimeout(params: FrameWaitForTimeoutParams, metadata?: Metadata): Promise<FrameWaitForTimeoutResult>;
waitForFunction(params: FrameWaitForFunctionParams, metadata?: Metadata): Promise<FrameWaitForFunctionResult>;
waitForSelector(params: FrameWaitForSelectorParams, metadata?: Metadata): Promise<FrameWaitForSelectorResult>;
expect(params: FrameExpectParams, metadata?: Metadata): Promise<FrameExpectResult>;
}
export type FrameLoadstateEvent = {
add?: 'load' | 'domcontentloaded' | 'networkidle',
Expand Down Expand Up @@ -2172,6 +2182,25 @@ export type FrameWaitForSelectorOptions = {
export type FrameWaitForSelectorResult = {
element?: ElementHandleChannel,
};
export type FrameExpectParams = {
selector: string,
expression: string,
expected?: ExpectedTextValue,
isNot?: boolean,
data?: any,
timeout?: number,
};
export type FrameExpectOptions = {
expected?: ExpectedTextValue,
isNot?: boolean,
data?: any,
timeout?: number,
};
export type FrameExpectResult = {
pass: boolean,
received: string,
log: string[],
};

export interface FrameEvents {
'loadstate': FrameLoadstateEvent;
Expand Down Expand Up @@ -3734,6 +3763,7 @@ export const commandsWithTracingSnapshots = new Set([
'Frame.waitForTimeout',
'Frame.waitForFunction',
'Frame.waitForSelector',
'Frame.expect',
'JSHandle.evaluateExpression',
'ElementHandle.evaluateExpression',
'JSHandle.evaluateExpressionHandle',
Expand Down
26 changes: 26 additions & 0 deletions src/protocol/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ SerializedArgument:
items: Channel


ExpectedTextValue:
type: object
properties:
string: string?
regexSource: string?
regexFlags: string?
matchSubstring: boolean?
normalizeWhiteSpace: boolean?
useInnerText: boolean?


AXNode:
type: object
properties:
Expand Down Expand Up @@ -1742,6 +1753,21 @@ Frame:
tracing:
snapshot: true

expect:
parameters:
selector: string
expression: string
expected: ExpectedTextValue?
isNot: boolean?
data: json?
timeout: number?
returns:
pass: boolean
received: string
log: string[]
tracing:
snapshot: true

events:

loadstate:
Expand Down
16 changes: 16 additions & 0 deletions src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
value: tType('SerializedValue'),
handles: tArray(tChannel('*')),
});
scheme.ExpectedTextValue = tObject({
string: tOptional(tString),
regexSource: tOptional(tString),
regexFlags: tOptional(tString),
matchSubstring: tOptional(tBoolean),
normalizeWhiteSpace: tOptional(tBoolean),
useInnerText: tOptional(tBoolean),
});
scheme.AXNode = tObject({
role: tString,
name: tString,
Expand Down Expand Up @@ -876,6 +884,14 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
timeout: tOptional(tNumber),
state: tOptional(tEnum(['attached', 'detached', 'visible', 'hidden'])),
});
scheme.FrameExpectParams = tObject({
selector: tString,
expression: tString,
expected: tOptional(tType('ExpectedTextValue')),
isNot: tOptional(tBoolean),
data: tOptional(tAny),
timeout: tOptional(tNumber),
});
scheme.WorkerEvaluateExpressionParams = tObject({
expression: tString,
isFunction: tOptional(tBoolean),
Expand Down
28 changes: 14 additions & 14 deletions src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import * as channels from '../protocol/channels';
import { FatalDOMError, RetargetableDOMError } from './common/domErrors';
import { isSessionClosedError } from './common/protocolError';
import * as frames from './frames';
import type { InjectedScript, InjectedScriptPoll } from './injected/injectedScript';
import type { InjectedScript, InjectedScriptPoll, LogEntry } from './injected/injectedScript';
import { CallMetadata } from './instrumentation';
import * as js from './javascript';
import { Page } from './page';
Expand Down Expand Up @@ -672,7 +672,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {

async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
const isChecked = async () => {
const result = await this.evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'checked'), {});
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
};
if (await isChecked() === state)
Expand Down Expand Up @@ -723,34 +723,34 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}

async isVisible(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'visible'), {});
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'visible'), {});
if (result === 'error:notconnected')
return false;
return throwFatalDOMError(result);
}

async isHidden(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'hidden'), {});
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'hidden'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}

async isEnabled(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'enabled'), {});
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'enabled'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}

async isDisabled(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'disabled'), {});
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'disabled'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}

async isEditable(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'editable'), {});
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'editable'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}

async isChecked(): Promise<boolean> {
const result = await this.evaluateInUtility(([injected, node]) => injected.checkElementState(node, 'checked'), {});
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
return throwRetargetableDOMError(throwFatalDOMError(result));
}

Expand Down Expand Up @@ -850,11 +850,11 @@ export class InjectedScriptPollHandler<T> {

private async _streamLogs() {
while (this._poll && this._progress.isRunning()) {
const messages = await this._poll.evaluate(poll => poll.takeNextLogs()).catch(e => [] as string[]);
const log = await this._poll.evaluate(poll => poll.takeNextLogs()).catch(e => [] as LogEntry[]);
if (!this._poll || !this._progress.isRunning())
return;
for (const message of messages)
this._progress.log(message);
for (const entry of log)
this._progress.logEntry(entry);
}
}

Expand Down Expand Up @@ -886,9 +886,9 @@ export class InjectedScriptPollHandler<T> {
if (!this._poll)
return;
// Retrieve all the logs before continuing.
const messages = await this._poll.evaluate(poll => poll.takeLastLogs()).catch(e => [] as string[]);
for (const message of messages)
this._progress.log(message);
const log = await this._poll.evaluate(poll => poll.takeLastLogs()).catch(e => [] as LogEntry[]);
for (const entry of log)
this._progress.logEntry(entry);
}

async cancel() {
Expand Down
97 changes: 88 additions & 9 deletions src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export type NavigationEvent = {
};

export type SchedulableTask<T> = (injectedScript: js.JSHandle<InjectedScript>) => Promise<js.JSHandle<InjectedScriptPoll<T>>>;
export type DomTaskBody<T, R> = (progress: InjectedScriptProgress, element: Element, data: T) => R;
export type DomTaskBody<T, R> = (progress: InjectedScriptProgress, element: Element, data: T, continuePolling: any) => R;

export class FrameManager {
private _page: Page;
Expand Down Expand Up @@ -1067,10 +1067,10 @@ export class Frame extends SdkObject {
}, undefined, options);
}

private async _checkElementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions = {}): Promise<boolean> {
private async _elementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions = {}): Promise<boolean> {
const result = await this._scheduleRerunnableTask(metadata, selector, (progress, element, data) => {
const injected = progress.injectedScript;
return injected.checkElementState(element, data.state);
return injected.elementState(element, data.state);
}, { state }, options);
return dom.throwFatalDOMError(dom.throwRetargetableDOMError(result));
}
Expand All @@ -1089,19 +1089,19 @@ export class Frame extends SdkObject {
}

async isDisabled(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise<boolean> {
return this._checkElementState(metadata, selector, 'disabled', options);
return this._elementState(metadata, selector, 'disabled', options);
}

async isEnabled(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise<boolean> {
return this._checkElementState(metadata, selector, 'enabled', options);
return this._elementState(metadata, selector, 'enabled', options);
}

async isEditable(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise<boolean> {
return this._checkElementState(metadata, selector, 'editable', options);
return this._elementState(metadata, selector, 'editable', options);
}

async isChecked(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise<boolean> {
return this._checkElementState(metadata, selector, 'checked', options);
return this._elementState(metadata, selector, 'checked', options);
}

async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions = {}) {
Expand Down Expand Up @@ -1160,6 +1160,81 @@ export class Frame extends SdkObject {
});
}

async expect(metadata: CallMetadata, selector: string, expression: string, options: channels.FrameExpectParams): Promise<{ pass: boolean, received: string, 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 => {
if (js.isJavaScriptErrorInEvaluate(e))
throw e;
return { received: controller.lastIntermediateResult(), pass: options.isNot, log: metadata.log };
});
}

async _waitForFunctionExpression<R>(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions, world: types.World = 'main'): Promise<js.SmartHandle<R>> {
const controller = new ProgressController(metadata, this);
if (typeof options.pollingInterval === 'number')
Expand Down Expand Up @@ -1219,6 +1294,10 @@ export class Frame extends SdkObject {

private async _scheduleRerunnableTask<T, R>(metadata: CallMetadata, selector: string, body: DomTaskBody<T, R>, taskData: T, options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean } = {}): Promise<R> {
const controller = new ProgressController(metadata, this);
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> {
const info = this._page.parseSelector(selector, options);
const callbackText = body.toString();
const data = this._contextData.get(options.mainWorld ? 'main' : info.world)!;
Expand All @@ -1231,8 +1310,8 @@ export class Frame extends SdkObject {
const element = injected.querySelector(info.parsed, document, info.strict);
if (!element)
return continuePolling;
progress.log(` selector resolved to ${injected.previewNode(element)}`);
return callback(progress, element, taskData);
progress.logRepeating(` selector resolved to ${injected.previewNode(element)}`);
return callback(progress, element, taskData, continuePolling);
});
}, { info, taskData, callbackText });
}, true);
Expand Down
Loading