diff --git a/src/client/locator.ts b/src/client/locator.ts
index 5cc07e36d6..ece94e462f 100644
--- a/src/client/locator.ts
+++ b/src/client/locator.ts
@@ -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();
}
diff --git a/src/dispatchers/frameDispatcher.ts b/src/dispatchers/frameDispatcher.ts
index 25af5311db..061b7f6e58 100644
--- a/src/dispatchers/frameDispatcher.ts
+++ b/src/dispatchers/frameDispatcher.ts
@@ -226,4 +226,8 @@ export class FrameDispatcher extends Dispatcher {
return { value: await this._frame.title() };
}
+
+ async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise {
+ return await this._frame.expect(metadata, params.selector, params.expression, params);
+ }
}
diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts
index 12e8079435..69b008f74f 100644
--- a/src/protocol/channels.ts
+++ b/src/protocol/channels.ts
@@ -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,
@@ -1609,6 +1618,7 @@ export interface FrameChannel extends Channel {
waitForTimeout(params: FrameWaitForTimeoutParams, metadata?: Metadata): Promise;
waitForFunction(params: FrameWaitForFunctionParams, metadata?: Metadata): Promise;
waitForSelector(params: FrameWaitForSelectorParams, metadata?: Metadata): Promise;
+ expect(params: FrameExpectParams, metadata?: Metadata): Promise;
}
export type FrameLoadstateEvent = {
add?: 'load' | 'domcontentloaded' | 'networkidle',
@@ -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;
@@ -3734,6 +3763,7 @@ export const commandsWithTracingSnapshots = new Set([
'Frame.waitForTimeout',
'Frame.waitForFunction',
'Frame.waitForSelector',
+ 'Frame.expect',
'JSHandle.evaluateExpression',
'ElementHandle.evaluateExpression',
'JSHandle.evaluateExpressionHandle',
diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml
index 536cafbcc7..7456ad7cea 100644
--- a/src/protocol/protocol.yml
+++ b/src/protocol/protocol.yml
@@ -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:
@@ -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:
diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts
index ff6354b840..a37a99047a 100644
--- a/src/protocol/validator.ts
+++ b/src/protocol/validator.ts
@@ -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,
@@ -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),
diff --git a/src/server/dom.ts b/src/server/dom.ts
index d1fb28d031..ec34dd4230 100644
--- a/src/server/dom.ts
+++ b/src/server/dom.ts
@@ -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';
@@ -672,7 +672,7 @@ export class ElementHandle extends js.JSHandle {
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)
@@ -723,34 +723,34 @@ export class ElementHandle extends js.JSHandle {
}
async isVisible(): Promise {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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));
}
@@ -850,11 +850,11 @@ export class InjectedScriptPollHandler {
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);
}
}
@@ -886,9 +886,9 @@ export class InjectedScriptPollHandler {
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() {
diff --git a/src/server/frames.ts b/src/server/frames.ts
index f050e6913a..67ecd9a409 100644
--- a/src/server/frames.ts
+++ b/src/server/frames.ts
@@ -69,7 +69,7 @@ export type NavigationEvent = {
};
export type SchedulableTask = (injectedScript: js.JSHandle) => Promise>>;
-export type DomTaskBody = (progress: InjectedScriptProgress, element: Element, data: T) => R;
+export type DomTaskBody = (progress: InjectedScriptProgress, element: Element, data: T, continuePolling: any) => R;
export class FrameManager {
private _page: Page;
@@ -1067,10 +1067,10 @@ export class Frame extends SdkObject {
}, undefined, options);
}
- private async _checkElementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions = {}): Promise {
+ private async _elementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions = {}): Promise {
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));
}
@@ -1089,19 +1089,19 @@ export class Frame extends SdkObject {
}
async isDisabled(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise {
- return this._checkElementState(metadata, selector, 'disabled', options);
+ return this._elementState(metadata, selector, 'disabled', options);
}
async isEnabled(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise {
- return this._checkElementState(metadata, selector, 'enabled', options);
+ return this._elementState(metadata, selector, 'enabled', options);
}
async isEditable(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise {
- return this._checkElementState(metadata, selector, 'editable', options);
+ return this._elementState(metadata, selector, 'editable', options);
}
async isChecked(metadata: CallMetadata, selector: string, options: types.QueryOnSelectorOptions = {}): Promise {
- 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 = {}) {
@@ -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(metadata: CallMetadata, expression: string, isFunction: boolean | undefined, arg: any, options: types.WaitForFunctionOptions, world: types.World = 'main'): Promise> {
const controller = new ProgressController(metadata, this);
if (typeof options.pollingInterval === 'number')
@@ -1219,6 +1294,10 @@ export class Frame extends SdkObject {
private async _scheduleRerunnableTask(metadata: CallMetadata, selector: string, body: DomTaskBody, taskData: T, options: types.TimeoutOptions & types.StrictOptions & { mainWorld?: boolean } = {}): Promise {
const controller = new ProgressController(metadata, this);
+ 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 {
const info = this._page.parseSelector(selector, options);
const callbackText = body.toString();
const data = this._contextData.get(options.mainWorld ? 'main' : info.world)!;
@@ -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);
diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts
index 41a3de073e..435bbe26de 100644
--- a/src/server/injected/injectedScript.ts
+++ b/src/server/injected/injectedScript.ts
@@ -23,22 +23,29 @@ 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';
type Predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
export type InjectedScriptProgress = {
- injectedScript: InjectedScript,
- aborted: boolean,
- log: (message: string) => void,
- logRepeating: (message: string) => void,
+ injectedScript: InjectedScript;
+ aborted: boolean;
+ log: (message: string) => void;
+ logRepeating: (message: string) => void;
+ setIntermediateResult: (intermediateResult: any) => void;
+};
+
+export type LogEntry = {
+ message?: string;
+ intermediateResult?: string;
};
export type InjectedScriptPoll = {
run: () => Promise,
// Takes more logs, waiting until at least one message is available.
- takeNextLogs: () => Promise,
+ takeNextLogs: () => Promise,
// Takes all current logs without waiting.
- takeLastLogs: () => string[],
+ takeLastLogs: () => LogEntry[],
cancel: () => void,
};
@@ -311,36 +318,44 @@ export class InjectedScript {
}
private _runAbortableTask(task: (progess: InjectedScriptProgress) => Promise): InjectedScriptPoll {
- let unsentLogs: string[] = [];
- let takeNextLogsCallback: ((logs: string[]) => void) | undefined;
+ let unsentLog: LogEntry[] = [];
+ let takeNextLogsCallback: ((logs: LogEntry[]) => void) | undefined;
let taskFinished = false;
const logReady = () => {
if (!takeNextLogsCallback)
return;
- takeNextLogsCallback(unsentLogs);
- unsentLogs = [];
+ takeNextLogsCallback(unsentLog);
+ unsentLog = [];
takeNextLogsCallback = undefined;
};
- const takeNextLogs = () => new Promise(fulfill => {
+ const takeNextLogs = () => new Promise(fulfill => {
takeNextLogsCallback = fulfill;
- if (unsentLogs.length || taskFinished)
+ if (unsentLog.length || taskFinished)
logReady();
});
- let lastLog = '';
+ let lastMessage = '';
+ let lastIntermediateResult: any = undefined;
const progress: InjectedScriptProgress = {
injectedScript: this,
aborted: false,
log: (message: string) => {
- lastLog = message;
- unsentLogs.push(message);
+ lastMessage = message;
+ unsentLog.push({ message });
logReady();
},
logRepeating: (message: string) => {
- if (message !== lastLog)
+ if (message !== lastMessage)
progress.log(message);
},
+ setIntermediateResult: (intermediateResult: any) => {
+ if (lastIntermediateResult === intermediateResult)
+ return;
+ lastIntermediateResult = intermediateResult;
+ unsentLog.push({ intermediateResult });
+ logReady();
+ },
};
const run = () => {
@@ -361,7 +376,7 @@ export class InjectedScript {
takeNextLogs,
run,
cancel: () => { progress.aborted = true; },
- takeLastLogs: () => unsentLogs,
+ takeLastLogs: () => unsentLog,
};
}
@@ -405,7 +420,7 @@ export class InjectedScript {
for (const state of states) {
if (state !== 'stable') {
- const result = this.checkElementState(node, state);
+ const result = this.elementState(node, state);
if (typeof result !== 'boolean')
return result;
if (!result) {
@@ -456,7 +471,7 @@ export class InjectedScript {
return this.pollRaf(predicate);
}
- checkElementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | FatalDOMError {
+ elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' | 'error:notcheckbox' {
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'no-follow-label' : 'follow-label');
if (!element || !element.isConnected) {
if (state === 'hidden')
@@ -761,6 +776,10 @@ export class InjectedScript {
delete error.stack;
return error;
}
+
+ expectedTextMatcher(expected: ExpectedTextValue): ExpectedTextMatcher {
+ return new ExpectedTextMatcher(expected);
+ }
}
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
@@ -850,4 +869,36 @@ function createTextMatcher(selector: string): { matcher: TextMatcher, kind: 'reg
return { matcher, kind: strict ? 'strict' : 'lax' };
}
+class ExpectedTextMatcher {
+ _string: string | undefined;
+ private _substring: string | undefined;
+ private _regex: RegExp | undefined;
+ private _normalizeWhiteSpace: boolean | undefined;
+
+ constructor(expected: ExpectedTextValue) {
+ this._normalizeWhiteSpace = expected.normalizeWhiteSpace;
+ this._string = expected.matchSubstring ? undefined : this.normalizeWhiteSpace(expected.string);
+ this._substring = expected.matchSubstring ? this.normalizeWhiteSpace(expected.string) : undefined;
+ this._regex = expected.regexSource ? new RegExp(expected.regexSource, expected.regexFlags) : undefined;
+ }
+
+ matches(text: string): boolean {
+ if (this._normalizeWhiteSpace && !this._regex)
+ text = this.normalizeWhiteSpace(text)!;
+ if (this._string !== undefined)
+ return text === this._string;
+ if (this._substring !== undefined)
+ return text.includes(this._substring);
+ if (this._regex)
+ return !!this._regex.test(text);
+ return false;
+ }
+
+ private normalizeWhiteSpace(s: string | undefined): string | undefined {
+ if (!s)
+ return s;
+ return this._normalizeWhiteSpace ? s.trim().replace(/\s+/g, ' ') : s;
+ }
+}
+
export default InjectedScript;
diff --git a/src/server/progress.ts b/src/server/progress.ts
index 0e7e9cfb33..052ad74f71 100644
--- a/src/server/progress.ts
+++ b/src/server/progress.ts
@@ -19,9 +19,12 @@ import { assert, monotonicTime } from '../utils/utils';
import { LogName } from '../utils/debugLogger';
import { CallMetadata, Instrumentation, SdkObject } from './instrumentation';
import { ElementHandle } from './dom';
+import { ManualPromise } from '../utils/async';
+import type { LogEntry } from './injected/injectedScript';
export interface Progress {
log(message: string): void;
+ logEntry(entry: LogEntry): void;
timeUntilDeadline(): number;
isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void;
@@ -31,10 +34,7 @@ export interface Progress {
}
export class ProgressController {
- // Promise and callback that forcefully abort the progress.
- // This promise always rejects.
- private _forceAbort: (error: Error) => void = () => {};
- private _forceAbortPromise: Promise;
+ private _forceAbortPromise = new ManualPromise();
// Cleanups to be run only in the case of abort.
private _cleanups: (() => any)[] = [];
@@ -43,6 +43,7 @@ export class ProgressController {
private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before';
private _deadline: number = 0;
private _timeout: number = 0;
+ private _lastIntermediateResult: any;
readonly metadata: CallMetadata;
readonly instrumentation: Instrumentation;
readonly sdkObject: SdkObject;
@@ -51,7 +52,6 @@ export class ProgressController {
this.metadata = metadata;
this.sdkObject = sdkObject;
this.instrumentation = sdkObject.instrumentation;
- this._forceAbortPromise = new Promise((resolve, reject) => this._forceAbort = reject);
this._forceAbortPromise.catch(e => null); // Prevent unhandled promise rejection.
}
@@ -59,6 +59,10 @@ export class ProgressController {
this._logName = logName;
}
+ lastIntermediateResult() {
+ return this._lastIntermediateResult;
+ }
+
async run(task: (progress: Progress) => Promise, timeout?: number): Promise {
if (timeout) {
this._timeout = timeout;
@@ -70,10 +74,18 @@ export class ProgressController {
const progress: Progress = {
log: message => {
- if (this._state === 'running')
- this.metadata.log.push(message);
- // Note: we might be sending logs after progress has finished, for example browser logs.
- this.instrumentation.onCallLog(this._logName, message, this.sdkObject, this.metadata);
+ progress.logEntry({ message });
+ },
+ logEntry: entry => {
+ if ('message' in entry) {
+ const message = entry.message!;
+ if (this._state === 'running')
+ this.metadata.log.push(message);
+ // Note: we might be sending logs after progress has finished, for example browser logs.
+ this.instrumentation.onCallLog(this._logName, message, this.sdkObject, this.metadata);
+ }
+ if ('intermediateResult' in entry)
+ this._lastIntermediateResult = entry.intermediateResult;
},
timeUntilDeadline: () => this._deadline ? this._deadline - monotonicTime() : 2147483647, // 2^31-1 safe setTimeout in Node.
isRunning: () => this._state === 'running',
@@ -94,7 +106,7 @@ export class ProgressController {
};
const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`);
- const timer = setTimeout(() => this._forceAbort(timeoutError), progress.timeUntilDeadline());
+ const timer = setTimeout(() => this._forceAbortPromise.reject(timeoutError), progress.timeUntilDeadline());
try {
const promise = task(progress);
const result = await Promise.race([promise, this._forceAbortPromise]);
diff --git a/src/test/loader.ts b/src/test/loader.ts
index ad74e4d963..a637383eb7 100644
--- a/src/test/loader.ts
+++ b/src/test/loader.ts
@@ -16,7 +16,7 @@
import { installTransform } from './transform';
import type { FullConfig, Config, FullProject, Project, ReporterDescription, PreserveOutput } from './types';
-import { isRegExp, mergeObjects, errorWithFile } from './util';
+import { mergeObjects, errorWithFile } from './util';
import { setCurrentlyLoadingFileSuite } from './globals';
import { Suite } from './test';
import { SerializedLoaderData } from './ipc';
@@ -26,6 +26,7 @@ import * as fs from 'fs';
import { ProjectImpl } from './project';
import { Reporter } from '../../types/testReporter';
import { BuiltInReporter, builtInReporters } from './runner';
+import { isRegExp } from '../utils/utils';
export class Loader {
private _defaultConfig: Config;
diff --git a/src/test/matchers/matchers.ts b/src/test/matchers/matchers.ts
index 44cf82bff1..7eeb14c3fe 100644
--- a/src/test/matchers/matchers.ts
+++ b/src/test/matchers/matchers.ts
@@ -26,8 +26,8 @@ export function toBeChecked(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async timeout => {
- return await locator.isChecked({ timeout });
+ return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.checked', { isNot, timeout });
}, options);
}
@@ -36,8 +36,8 @@ export function toBeDisabled(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async timeout => {
- return await locator.isDisabled({ timeout });
+ return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.disabled', { isNot, timeout });
}, options);
}
@@ -46,8 +46,8 @@ export function toBeEditable(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async timeout => {
- return await locator.isEditable({ timeout });
+ return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.editable', { isNot, timeout });
}, options);
}
@@ -56,12 +56,8 @@ export function toBeEmpty(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async timeout => {
- return await locator.evaluate(element => {
- if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
- return !(element as HTMLInputElement).value;
- return !element.textContent?.trim();
- }, { timeout });
+ return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.empty', { isNot, timeout });
}, options);
}
@@ -70,8 +66,8 @@ export function toBeEnabled(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async timeout => {
- return await locator.isEnabled({ timeout });
+ return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.enabled', { isNot, timeout });
}, options);
}
@@ -80,10 +76,8 @@ export function toBeFocused(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async timeout => {
- return await locator.evaluate(element => {
- return document.activeElement === element;
- }, { timeout });
+ return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.focused', { isNot, timeout });
}, options);
}
@@ -92,8 +86,8 @@ export function toBeHidden(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async timeout => {
- return await locator.isHidden({ timeout });
+ return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.hidden', { isNot, timeout });
}, options);
}
@@ -102,8 +96,8 @@ export function toBeVisible(
locator: Locator,
options?: { timeout?: number },
) {
- return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async timeout => {
- return await locator.isVisible({ timeout });
+ return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', async (isNot, timeout) => {
+ return await (locator as any)._expect('to.be.visible', { isNot, timeout });
}, options);
}
@@ -113,11 +107,9 @@ export function toContainText(
expected: string,
options?: { timeout?: number, useInnerText?: boolean },
) {
- return toMatchText.call(this, 'toContainText', locator, 'Locator', async timeout => {
- if (options?.useInnerText)
- return await locator.innerText({ timeout });
- return await locator.textContent() || '';
- }, expected, { ...options, matchSubstring: true, normalizeWhiteSpace: true });
+ 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 });
}
export function toHaveAttribute(
@@ -127,8 +119,8 @@ export function toHaveAttribute(
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveAttribute', locator, 'Locator', async timeout => {
- return await locator.getAttribute(name, { timeout }) || '';
+ 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 } });
}, expected, options);
}
@@ -143,8 +135,8 @@ export function toHaveClass(
return await locator.evaluateAll(ee => ee.map(e => e.className));
}, expected, options);
} else {
- return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async timeout => {
- return await locator.evaluate(element => element.className, { timeout });
+ return toMatchText.call(this, 'toHaveClass', locator, 'Locator', async (expected, isNot, timeout) => {
+ return await (locator as any)._expect('to.have.class', { expected, isNot, timeout });
}, expected, options);
}
}
@@ -167,10 +159,8 @@ export function toHaveCSS(
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveCSS', locator, 'Locator', async timeout => {
- return await locator.evaluate(async (element, name) => {
- return (window.getComputedStyle(element) as any)[name];
- }, name, { timeout });
+ 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 } });
}, expected, options);
}
@@ -180,8 +170,8 @@ export function toHaveId(
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveId', locator, 'Locator', async timeout => {
- return await locator.getAttribute('id', { timeout }) || '';
+ return toMatchText.call(this, 'toHaveId', locator, 'Locator', async (expected, isNot, timeout) => {
+ return await (locator as any)._expect('to.have.id', { expected, isNot, timeout });
}, expected, options);
}
@@ -213,11 +203,9 @@ export function toHaveText(
return texts.map((s, index) => isString(expectedArray[index]) ? normalizeWhiteSpace(s) : s);
}, expectedArray, options);
} else {
- return toMatchText.call(this, 'toHaveText', locator, 'Locator', async timeout => {
- if (options?.useInnerText)
- return await locator.innerText({ timeout });
- return await locator.textContent() || '';
- }, expected, { ...options, normalizeWhiteSpace: true });
+ 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 });
}
}
@@ -227,8 +215,9 @@ export function toHaveTitle(
expected: string | RegExp,
options: { timeout?: number } = {},
) {
- return toMatchText.call(this, 'toHaveTitle', page, 'Page', async () => {
- return await page.title();
+ 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 });
}
@@ -239,10 +228,11 @@ export function toHaveURL(
options?: { timeout?: number },
) {
const baseURL = (page.context() as any)._options.baseURL;
-
- return toMatchText.call(this, 'toHaveURL', page, 'Page', async () => {
- return page.url();
- }, typeof expected === 'string' ? constructURLBasedOnBaseURL(baseURL, expected) : expected, options);
+ 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 });
+ }, expected, options);
}
export function toHaveValue(
@@ -251,7 +241,7 @@ export function toHaveValue(
expected: string | RegExp,
options?: { timeout?: number },
) {
- return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async timeout => {
- return await locator.inputValue({ timeout });
+ return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (expected, isNot, timeout) => {
+ return await (locator as any)._expect('to.have.value', { expected, isNot, timeout });
}, expected, options);
}
diff --git a/src/test/matchers/toBeTruthy.ts b/src/test/matchers/toBeTruthy.ts
index 46f1f9082f..7d14a08b42 100644
--- a/src/test/matchers/toBeTruthy.ts
+++ b/src/test/matchers/toBeTruthy.ts
@@ -16,14 +16,14 @@
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
-import { expectType, pollUntilDeadline } from '../util';
+import { expectType } from '../util';
-export async function toBeTruthy(
+export async function toBeTruthy(
this: ReturnType,
matcherName: string,
receiver: any,
receiverType: string,
- query: (timeout: number) => Promise,
+ query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean }>,
options: { timeout?: number } = {},
) {
const testInfo = currentTestInfo();
@@ -36,14 +36,12 @@ export async function toBeTruthy(
promise: this.promise,
};
- let received: T;
- 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 = !!received;
- return pass === !matcherOptions.isNot;
- }, options.timeout, testInfo._testFinished);
+ const { pass } = await query(this.isNot, timeout);
const message = () => {
return this.utils.matcherHint(matcherName, undefined, '', matcherOptions);
diff --git a/src/test/matchers/toMatchText.ts b/src/test/matchers/toMatchText.ts
index 68b0bfec84..119156064c 100644
--- a/src/test/matchers/toMatchText.ts
+++ b/src/test/matchers/toMatchText.ts
@@ -18,19 +18,20 @@ import {
printReceivedStringContainExpectedResult,
printReceivedStringContainExpectedSubstring
} from 'expect/build/print';
-import { isString } from '../../utils/utils';
+import { ExpectedTextValue } from '../../protocol/channels';
+import { isRegExp, isString } from '../../utils/utils';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
-import { expectType, pollUntilDeadline } from '../util';
+import { expectType } from '../util';
export async function toMatchText(
this: ReturnType,
matcherName: string,
receiver: any,
receiverType: string,
- query: (timeout: number) => Promise,
+ query: (expected: ExpectedTextValue, isNot: boolean, timeout: number) => Promise<{ pass: boolean, received: string }>,
expected: string | RegExp,
- options: { timeout?: number, matchSubstring?: boolean, normalizeWhiteSpace?: boolean } = {},
+ options: { timeout?: number, matchSubstring?: boolean, normalizeWhiteSpace?: boolean, useInnerText?: boolean } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
@@ -57,25 +58,21 @@ export async function toMatchText(
);
}
- let received: string;
- let pass = false;
- if (options.normalizeWhiteSpace && isString(expected))
- expected = normalizeWhiteSpace(expected);
+ 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);
- if (options.normalizeWhiteSpace && isString(expected))
- received = normalizeWhiteSpace(received);
- if (options.matchSubstring)
- pass = received.includes(expected as string);
- else if (typeof expected === 'string')
- pass = received === expected;
- else
- pass = expected.test(received);
-
- return pass === !matcherOptions.isNot;
- }, options.timeout, testInfo._testFinished);
+ 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 stringSubstring = options.matchSubstring ? 'substring' : 'string';
const message = pass
? () =>
@@ -83,7 +80,7 @@ export async function toMatchText(
? this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
`Expected ${stringSubstring}: not ${this.utils.printExpected(expected)}\n` +
- `Received string: ${printReceivedStringContainExpectedSubstring(
+ `Received string: ${printReceivedStringContainExpectedSubstring(
received,
received.indexOf(expected),
expected.length,
@@ -91,7 +88,7 @@ export async function toMatchText(
: this.utils.matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
`Expected pattern: not ${this.utils.printExpected(expected)}\n` +
- `Received string: ${printReceivedStringContainExpectedResult(
+ `Received string: ${printReceivedStringContainExpectedResult(
received,
typeof expected.exec === 'function'
? expected.exec(received)
diff --git a/src/test/util.ts b/src/test/util.ts
index eee6da7e11..506d69c57a 100644
--- a/src/test/util.ts
+++ b/src/test/util.ts
@@ -22,6 +22,7 @@ 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;
@@ -82,10 +83,6 @@ export function monotonicTime(): number {
return seconds * 1000 + (nanoseconds / 1000000 | 0);
}
-export function isRegExp(e: any): e is RegExp {
- return e && typeof e === 'object' && (e instanceof RegExp || Object.prototype.toString.call(e) === '[object RegExp]');
-}
-
export type Matcher = (value: string) => boolean;
export type FilePatternFilter = {
diff --git a/tests/playwright-test/playwright.expect.text.spec.ts b/tests/playwright-test/playwright.expect.text.spec.ts
index e9d8f781ad..06f25e3f04 100644
--- a/tests/playwright-test/playwright.expect.text.spec.ts
+++ b/tests/playwright-test/playwright.expect.text.spec.ts
@@ -84,6 +84,34 @@ test('should support toHaveText w/ text', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1);
});
+test('should support toHaveText w/ not', async ({ runInlineTest }) => {
+ const result = await runInlineTest({
+ 'a.test.ts': `
+ const { test } = pwt;
+
+ test('pass', async ({ page }) => {
+ await page.setContent('Text content
');
+ const locator = page.locator('#node');
+ await expect(locator).not.toHaveText('Text2');
+ });
+
+ test('fail', async ({ page }) => {
+ await page.setContent('Text content
');
+ const locator = page.locator('#node');
+ await expect(locator).not.toHaveText('Text content', { timeout: 100 });
+ });
+ `,
+ }, { workers: 1 });
+ const output = stripAscii(result.output);
+ expect(output).toContain('Error: expect(received).not.toHaveText(expected)');
+ expect(output).toContain('Expected string: not "Text content"');
+ expect(output).toContain('Received string: "Text content');
+ expect(output).toContain('expect(locator).not.toHaveText');
+ expect(result.passed).toBe(1);
+ expect(result.failed).toBe(1);
+ expect(result.exitCode).toBe(1);
+});
+
test('should support toHaveText w/ array', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
@@ -213,6 +241,41 @@ test('should support toHaveValue', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
});
+test('should support toHaveValue regex', async ({ runInlineTest }) => {
+ const result = await runInlineTest({
+ 'a.test.ts': `
+ const { test } = pwt;
+
+ test('pass', async ({ page }) => {
+ await page.setContent('');
+ const locator = page.locator('#node');
+ await locator.fill('Text content');
+ await expect(locator).toHaveValue(/Text/);
+ });
+ `,
+ }, { workers: 1 });
+ expect(result.passed).toBe(1);
+ expect(result.exitCode).toBe(0);
+});
+
+test('should support toHaveValue failing', async ({ runInlineTest }) => {
+ const result = await runInlineTest({
+ 'a.test.ts': `
+ const { test } = pwt;
+
+ test('pass', async ({ page }) => {
+ await page.setContent('');
+ const locator = page.locator('#node');
+ await locator.fill('Text content');
+ await expect(locator).toHaveValue(/Text2/, { timeout: 1000 });
+ });
+ `,
+ }, { workers: 1 });
+ expect(result.passed).toBe(0);
+ expect(result.exitCode).toBe(1);
+ expect(result.output).toContain('"Text content"');
+});
+
test('should print expected/received before timeout', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
diff --git a/tests/playwright-test/playwright.expect.true.spec.ts b/tests/playwright-test/playwright.expect.true.spec.ts
index c62320b641..fc37aef836 100644
--- a/tests/playwright-test/playwright.expect.true.spec.ts
+++ b/tests/playwright-test/playwright.expect.true.spec.ts
@@ -27,12 +27,6 @@ test('should support toBeChecked', async ({ runInlineTest }) => {
await expect(locator).toBeChecked();
});
- test('pass not', async ({ page }) => {
- await page.setContent('');
- const locator = page.locator('input');
- await expect(locator).not.toBeChecked();
- });
-
test('fail', async ({ page }) => {
await page.setContent('');
const locator = page.locator('input');
@@ -43,7 +37,33 @@ test('should support toBeChecked', async ({ runInlineTest }) => {
const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toBeChecked()');
expect(output).toContain('expect(locator).toBeChecked');
- expect(result.passed).toBe(2);
+ expect(result.passed).toBe(1);
+ expect(result.failed).toBe(1);
+ expect(result.exitCode).toBe(1);
+});
+
+test('should support toBeChecked w/ not', async ({ runInlineTest }) => {
+ const result = await runInlineTest({
+ 'a.test.ts': `
+ const { test } = pwt;
+
+ test('pass not', async ({ page }) => {
+ await page.setContent('');
+ const locator = page.locator('input');
+ await expect(locator).not.toBeChecked();
+ });
+
+ test('fail not', async ({ page }) => {
+ await page.setContent('');
+ const locator = page.locator('input');
+ await expect(locator).not.toBeChecked({ timeout: 1000 });
+ });
+ `,
+ }, { workers: 1 });
+ const output = stripAscii(result.output);
+ expect(output).toContain('Error: expect(received).not.toBeChecked()');
+ expect(output).toContain('expect(locator).not.toBeChecked');
+ expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts
index 097b98a835..f0f8f530bf 100644
--- a/tests/playwright-test/reporter.spec.ts
+++ b/tests/playwright-test/reporter.spec.ts
@@ -229,9 +229,9 @@ test('should report expect steps', async ({ runInlineTest }) => {
`%% end {\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"Before Hooks\",\"category\":\"hook\",\"steps\":[{\"title\":\"browserContext.newPage\",\"category\":\"pw:api\"}]}`,
`%% begin {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\"}`,
- `%% begin {\"title\":\"object.expect.toHaveTitle\",\"category\":\"pw:api\"}`,
- `%% end {\"title\":\"object.expect.toHaveTitle\",\"category\":\"pw:api\"}`,
- `%% end {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\",\"steps\":[{\"title\":\"object.expect.toHaveTitle\",\"category\":\"pw:api\"}]}`,
+ `%% begin {\"title\":\"object.expect.toHaveTitle(:root)\",\"category\":\"pw:api\"}`,
+ `%% end {\"title\":\"object.expect.toHaveTitle(:root)\",\"category\":\"pw:api\"}`,
+ `%% end {\"title\":\"expect.not.toHaveTitle\",\"category\":\"expect\",\"steps\":[{\"title\":\"object.expect.toHaveTitle(:root)\",\"category\":\"pw:api\"}]}`,
`%% begin {\"title\":\"After Hooks\",\"category\":\"hook\"}`,
`%% begin {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,
`%% end {\"title\":\"browserContext.close\",\"category\":\"pw:api\"}`,
diff --git a/utils/check_deps.js b/utils/check_deps.js
index 7d49aa06ef..6359ea3308 100644
--- a/utils/check_deps.js
+++ b/utils/check_deps.js
@@ -163,7 +163,7 @@ DEPS['src/server/'] = [
// No dependencies for code shared between node and page.
DEPS['src/server/common/'] = [];
// Strict dependencies for injected code.
-DEPS['src/server/injected/'] = ['src/server/common/'];
+DEPS['src/server/injected/'] = ['src/server/common/', 'src/protocol/channels.ts'];
// Electron and Clank use chromium internally.
DEPS['src/server/android/'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/protocol/'];
@@ -193,7 +193,7 @@ DEPS['src/server/trace/recorder/'] = ['src/server/trace/common/', ...DEPS['src/s
DEPS['src/server/trace/viewer/'] = ['src/server/trace/common/', 'src/server/trace/recorder/', 'src/server/chromium/', ...DEPS['src/server/trace/common/']];
// Playwright Test
-DEPS['src/test/'] = ['src/test/**', 'src/utils/utils.ts', 'src/utils/**'];
+DEPS['src/test/'] = ['src/test/**', 'src/utils/utils.ts', 'src/utils/**', 'src/protocol/channels.ts'];
DEPS['src/test/index.ts'] = [... DEPS['src/test/'], 'src/grid/gridClient.ts' ];
// HTML report