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