diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 5b11eace1c164..45a0e2dc1cf90 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -711,6 +711,7 @@ Raw CSS content to be injected into frame. ## async method: Page.agent * since: v1.58 +* langs: js - returns: <[PageAgent]> Initialize page agent with the llm provider and cache. diff --git a/docs/src/api/class-pageagent.md b/docs/src/api/class-pageagent.md index 4914a8e00b75c..26242e8c4428d 100644 --- a/docs/src/api/class-pageagent.md +++ b/docs/src/api/class-pageagent.md @@ -1,5 +1,6 @@ # class: PageAgent * since: v1.58 +* langs: js ## event: PageAgent.turn * since: v1.58 @@ -95,3 +96,19 @@ Task to perform using agentic loop. ### option: PageAgent.perform.-inline- = %%-page-agent-call-options-v1.58-%% * since: v1.58 + +## async method: PageAgent.usage +* since: v1.58 +- returns: <[Object]> + - `turns` <[int]> + - `inputTokens` <[int]> + - `outputTokens` <[int]> + +Returns the current token usage for this agent. + +**Usage** + +```js +const usage = await agent.usage(); +console.log(`Tokens used: ${usage.inputTokens} in, ${usage.outputTokens} out`); +``` diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 1dfa0737ade3e..a9d3c3daff9fa 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -5462,6 +5462,25 @@ export interface PageAgent { }; }>; + /** + * Returns the current token usage for this agent. + * + * **Usage** + * + * ```js + * const usage = await agent.usage(); + * console.log(`Tokens used: ${usage.inputTokens} in, ${usage.outputTokens} out`); + * ``` + * + */ + usage(): Promise<{ + turns: number; + + inputTokens: number; + + outputTokens: number; + }>; + [Symbol.asyncDispose](): Promise; } diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index a184cda1e544f..196b7f7cd5d15 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -45,7 +45,7 @@ }, { "name": "webkit", - "revision": "2245", + "revision": "2248", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", diff --git a/packages/playwright-core/src/client/pageAgent.ts b/packages/playwright-core/src/client/pageAgent.ts index 7b3998a9e86ba..10d64a9c72f0c 100644 --- a/packages/playwright-core/src/client/pageAgent.ts +++ b/packages/playwright-core/src/client/pageAgent.ts @@ -56,6 +56,11 @@ export class PageAgent extends ChannelOwner implement return { result, usage }; } + async usage() { + const { usage } = await this._channel.usage({}); + return usage; + } + async dispose() { await this._channel.dispose(); } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 2f9161ad5196c..62dca989ba68a 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -2939,6 +2939,10 @@ scheme.PageAgentExtractResult = tObject({ }); scheme.PageAgentDisposeParams = tOptional(tObject({})); scheme.PageAgentDisposeResult = tOptional(tObject({})); +scheme.PageAgentUsageParams = tOptional(tObject({})); +scheme.PageAgentUsageResult = tObject({ + usage: tType('AgentUsage'), +}); scheme.AgentUsage = tObject({ turns: tInt, inputTokens: tInt, diff --git a/packages/playwright-core/src/server/agent/actions.ts b/packages/playwright-core/src/server/agent/actions.ts index dd058a5afd2b5..656a1f91f44ab 100644 --- a/packages/playwright-core/src/server/agent/actions.ts +++ b/packages/playwright-core/src/server/agent/actions.ts @@ -14,97 +14,123 @@ * limitations under the License. */ -export type NavigateAction = { - method: 'navigate'; - url: string; -}; - -export type ClickAction = { - method: 'click'; - selector: string; - button?: 'left' | 'right' | 'middle'; - clickCount?: number; - modifiers?: ('Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift')[]; -}; - -export type DragAction = { - method: 'drag'; - sourceSelector: string; - targetSelector: string; -}; - -export type HoverAction = { - method: 'hover'; - selector: string; - modifiers?: ('Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift')[]; -}; - -export type SelectOptionAction = { - method: 'selectOption'; - selector: string; - labels: string[]; -}; - -export type PressAction = { - method: 'pressKey'; - // Includes modifiers - key: string; -}; - -export type PressSequentiallyAction = { - method: 'pressSequentially'; - selector: string; - text: string; - submit?: boolean; -}; - -export type FillAction = { - method: 'fill'; - selector: string; - text: string; - submit?: boolean; -}; - -export type SetChecked = { - method: 'setChecked'; - selector: string; - checked: boolean; -}; - -export type ExpectVisible = { - method: 'expectVisible'; - selector: string; - isNot?: boolean; -}; - -export type ExpectValue = { - method: 'expectValue'; - selector: string; - type: 'textbox' | 'checkbox' | 'radio' | 'combobox' | 'slider'; - value: string; - isNot?: boolean; -}; - -export type ExpectAria = { - method: 'expectAria'; - template: string; - isNot?: boolean; -}; - -export type Action = - | NavigateAction - | ClickAction - | DragAction - | HoverAction - | SelectOptionAction - | PressAction - | PressSequentiallyAction - | FillAction - | SetChecked - | ExpectVisible - | ExpectValue - | ExpectAria; - -export type ActionWithCode = Action & { - code: string; -}; +import { zod } from '../../utilsBundle'; +import type z from 'zod'; + +const modifiersSchema = zod.array( + zod.enum(['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']) +); + +const navigateActionSchema = zod.object({ + method: zod.literal('navigate'), + url: zod.string(), +}); +export type NavigateAction = z.infer; + +const clickActionSchema = zod.object({ + method: zod.literal('click'), + selector: zod.string(), + button: zod.enum(['left', 'right', 'middle']).optional(), + clickCount: zod.number().optional(), + modifiers: modifiersSchema.optional(), +}); +export type ClickAction = z.infer; + +const dragActionSchema = zod.object({ + method: zod.literal('drag'), + sourceSelector: zod.string(), + targetSelector: zod.string(), +}); +export type DragAction = z.infer; + +const hoverActionSchema = zod.object({ + method: zod.literal('hover'), + selector: zod.string(), + modifiers: modifiersSchema.optional(), +}); +export type HoverAction = z.infer; + +const selectOptionActionSchema = zod.object({ + method: zod.literal('selectOption'), + selector: zod.string(), + labels: zod.array(zod.string()), +}); +export type SelectOptionAction = z.infer; + +const pressActionSchema = zod.object({ + method: zod.literal('pressKey'), + key: zod.string(), +}); +export type PressAction = z.infer; + +const pressSequentiallyActionSchema = zod.object({ + method: zod.literal('pressSequentially'), + selector: zod.string(), + text: zod.string(), + submit: zod.boolean().optional(), +}); +export type PressSequentiallyAction = z.infer; + +const fillActionSchema = zod.object({ + method: zod.literal('fill'), + selector: zod.string(), + text: zod.string(), + submit: zod.boolean().optional(), +}); +export type FillAction = z.infer; + +const setCheckedSchema = zod.object({ + method: zod.literal('setChecked'), + selector: zod.string(), + checked: zod.boolean(), +}); +export type SetChecked = z.infer; + +const expectVisibleSchema = zod.object({ + method: zod.literal('expectVisible'), + selector: zod.string(), + isNot: zod.boolean().optional(), +}); +export type ExpectVisible = z.infer; + +const expectValueSchema = zod.object({ + method: zod.literal('expectValue'), + selector: zod.string(), + type: zod.enum(['textbox', 'checkbox', 'radio', 'combobox', 'slider']), + value: zod.string(), + isNot: zod.boolean().optional(), +}); +export type ExpectValue = z.infer; + +const expectAriaSchema = zod.object({ + method: zod.literal('expectAria'), + template: zod.string(), + isNot: zod.boolean().optional(), +}); +export type ExpectAria = z.infer; + +const actionSchema = zod.discriminatedUnion('method', [ + navigateActionSchema, + clickActionSchema, + dragActionSchema, + hoverActionSchema, + selectOptionActionSchema, + pressActionSchema, + pressSequentiallyActionSchema, + fillActionSchema, + setCheckedSchema, + expectVisibleSchema, + expectValueSchema, + expectAriaSchema, +]); +export type Action = z.infer; + +const actionWithCodeSchema = actionSchema.and(zod.object({ + code: zod.string(), +})); +export type ActionWithCode = z.infer; + +export const cachedActionsSchema = zod.record(zod.object({ + actions: zod.array(actionWithCodeSchema), +})); +export type CachedActions = z.infer; diff --git a/packages/playwright-core/src/server/agent/pageAgent.ts b/packages/playwright-core/src/server/agent/pageAgent.ts index 20571dbe13ba6..ff573aa9e765c 100644 --- a/packages/playwright-core/src/server/agent/pageAgent.ts +++ b/packages/playwright-core/src/server/agent/pageAgent.ts @@ -25,7 +25,7 @@ import { Context } from './context'; import performTools from './performTools'; import expectTools from './expectTools'; -import type * as actions from './actions'; +import * as actions from './actions'; import type { ToolDefinition } from './tool'; import type * as loopTypes from '@lowire/loop'; import type { Progress } from '../progress'; @@ -151,10 +151,6 @@ async function runLoop(progress: Progress, context: Context, toolDefinitions: To return { result: resultSchema ? reportedResult() : undefined }; } -type CachedActions = Record; - async function cachedPerform(progress: Progress, context: Context, cacheKey: string): Promise { if (!context.agentParams?.cacheFile) return; @@ -191,8 +187,8 @@ async function updateCache(context: Context, cacheKey: string) { } type Cache = { - actions: CachedActions; - newActions: CachedActions; + actions: actions.CachedActions; + newActions: actions.CachedActions; }; const allCaches = new Map(); @@ -200,8 +196,11 @@ const allCaches = new Map(); async function cachedActions(cacheFile: string): Promise { let cache = allCaches.get(cacheFile); if (!cache) { - const actions = await fs.promises.readFile(cacheFile, 'utf-8').then(text => JSON.parse(text)).catch(() => ({})) as CachedActions; - cache = { actions, newActions: {} }; + const text = await fs.promises.readFile(cacheFile, 'utf-8').catch(() => '{}'); + const parsed = actions.cachedActionsSchema.safeParse(JSON.parse(text)); + if (parsed.error) + throw new Error(`Failed to parse cache file ${cacheFile}: ${parsed.error.issues.map(issue => issue.message).join(', ')}`); + cache = { actions: parsed.data, newActions: {} }; allCaches.set(cacheFile, cache); } return cache; diff --git a/packages/playwright-core/src/server/bidi/bidiBrowser.ts b/packages/playwright-core/src/server/bidi/bidiBrowser.ts index 46884d0a3e0f6..f92d3e171bf60 100644 --- a/packages/playwright-core/src/server/bidi/bidiBrowser.ts +++ b/packages/playwright-core/src/server/bidi/bidiBrowser.ts @@ -153,6 +153,7 @@ export class BidiBrowser extends Browser { context = this._defaultContext as BidiBrowserContext; if (!context) return; + context.doGrantGlobalPermissionsForURL(event.url); const session = this._connection.createMainFrameBrowsingContextSession(event.context); const opener = event.originalOpener && this._findPageForFrame(event.originalOpener); const page = new BidiPage(context, session, opener || null); @@ -237,6 +238,8 @@ export class BidiBrowserContext extends BrowserContext { } if (this._options.extraHTTPHeaders) promises.push(this.doUpdateExtraHTTPHeaders()); + if (this._options.permissions) + promises.push(this.doGrantPermissions('*', this._options.permissions)); await Promise.all(promises); } @@ -295,17 +298,36 @@ export class BidiBrowserContext extends BrowserContext { } async doGrantPermissions(origin: string, permissions: string[]) { + if (origin === 'null') + return; const currentPermissions = this._originToPermissions.get(origin) || []; const toGrant = permissions.filter(permission => !currentPermissions.includes(permission)); this._originToPermissions.set(origin, [...currentPermissions, ...toGrant]); - await Promise.all(toGrant.map(permission => this._setPermission(origin, permission, bidi.Permissions.PermissionState.Granted))); + if (origin === '*') { + await Promise.all(this._bidiPages().flatMap(page => + page._page.frames().map(frame => + this.doGrantPermissions(new URL(frame._url).origin, permissions) + ) + )); + } else { + await Promise.all(toGrant.map(permission => this._setPermission(origin, permission, bidi.Permissions.PermissionState.Granted))); + } + } + + async doGrantGlobalPermissionsForURL(url: string) { + const permissions = this._originToPermissions.get('*'); + if (!permissions) + return; + await this.doGrantPermissions(new URL(url).origin, permissions); } async doClearPermissions() { const currentPermissions = [...this._originToPermissions.entries()]; this._originToPermissions = new Map(); - await Promise.all(currentPermissions.map(([origin, permissions]) => permissions.map( - p => this._setPermission(origin, p, bidi.Permissions.PermissionState.Prompt)))); + await Promise.all(currentPermissions.flatMap(([origin, permissions]) => { + if (origin !== '*') + return permissions.map(p => this._setPermission(origin, p, bidi.Permissions.PermissionState.Prompt)); + })); } private async _setPermission(origin: string, permission: string, state: bidi.Permissions.PermissionState) { diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 7b47c062ddc95..60085d237940b 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { debugLogger } from '../utils/debugLogger'; import { eventsHelper } from '../utils/eventsHelper'; import * as dialog from '../dialog'; import * as dom from '../dom'; @@ -197,6 +198,7 @@ export class BidiPage implements PageDelegate { private _onNavigationCommitted(params: bidi.BrowsingContext.NavigationInfo) { const frameId = params.context; const frame = this._page.frameManager.frame(frameId)!; + this._browserContext.doGrantGlobalPermissionsForURL(params.url).catch(error => debugLogger.log('error', error)); this._page.frameManager.frameCommittedNewDocumentNavigation(frameId, params.url, frame._name, params.navigation!, /* initial */ false); } diff --git a/packages/playwright-core/src/server/dispatchers/pageAgentDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageAgentDispatcher.ts index d830d65dafadd..c30f551085b4d 100644 --- a/packages/playwright-core/src/server/dispatchers/pageAgentDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageAgentDispatcher.ts @@ -62,6 +62,10 @@ export class PageAgentDispatcher extends Dispatcher { + return { usage: this._usage }; + } + async dispose(params: channels.PageAgentDisposeParams, progress: Progress): Promise { } diff --git a/packages/playwright-core/src/server/utils/expectUtils.ts b/packages/playwright-core/src/server/utils/expectUtils.ts index 6d7a2a1e3db2f..3fac8db843da1 100644 --- a/packages/playwright-core/src/server/utils/expectUtils.ts +++ b/packages/playwright-core/src/server/utils/expectUtils.ts @@ -18,6 +18,16 @@ import { isRegExp, isString } from '../../utils/isomorphic/rtti'; import type { ExpectedTextValue } from '@protocol/channels'; +export interface InternalMatcherUtils { + printDiffOrStringify(expected: unknown, received: unknown, expectedLabel: string, receivedLabel: string, expand: boolean): string; + printExpected(value: unknown): string; + printReceived(object: unknown): string; + DIM_COLOR(text: string): string; + RECEIVED_COLOR(text: string): string; + INVERTED_COLOR(text: string): string; + EXPECTED_COLOR(text: string): string; +} + export function serializeExpectedTextValues(items: (string | RegExp)[], options: { matchSubstring?: boolean, normalizeWhiteSpace?: boolean, ignoreCase?: boolean } = {}): ExpectedTextValue[] { return items.map(i => ({ string: isString(i) ? i : undefined, @@ -28,3 +38,109 @@ export function serializeExpectedTextValues(items: (string | RegExp)[], options: normalizeWhiteSpace: options.normalizeWhiteSpace, })); } + +// #region +// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts with minor modifications. +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found here + * https://github.com/facebook/jest/blob/1547740bbc26400d69f4576bf35645163e942829/LICENSE + */ + +// Format substring but do not enclose in double quote marks. +// The replacement is compatible with pretty-format package. +const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&'); + +export const printReceivedStringContainExpectedSubstring = ( + utils: InternalMatcherUtils, + received: string, + start: number, + length: number, // not end +): string => + utils.RECEIVED_COLOR( + '"' + + printSubstring(received.slice(0, start)) + + utils.INVERTED_COLOR(printSubstring(received.slice(start, start + length))) + + printSubstring(received.slice(start + length)) + + '"', + ); + +export const printReceivedStringContainExpectedResult = ( + utils: InternalMatcherUtils, + received: string, + result: RegExpExecArray | null, +): string => + result === null + ? utils.printReceived(received) + : printReceivedStringContainExpectedSubstring( + utils, + received, + result.index, + result[0].length, + ); + +// #endregion + +type MatcherMessageDetails = { + promise?: '' | 'rejects' | 'resolves'; + isNot?: boolean; + receiver?: string; // Assuming 'locator' when locator is provided, 'page' otherwise. + matcherName: string; + expectation: string; + locator?: string; + printedExpected?: string; + printedReceived?: string; + printedDiff?: string; + timedOut?: boolean; + timeout?: number; + errorMessage?: string; + log?: string[]; +}; + +export function formatMatcherMessage(utils: InternalMatcherUtils, details: MatcherMessageDetails) { + const receiver = details.receiver ?? (details.locator ? 'locator' : 'page'); + let message = utils.DIM_COLOR('expect(') + utils.RECEIVED_COLOR(receiver) + + utils.DIM_COLOR(')' + (details.promise ? '.' + details.promise : '') + (details.isNot ? '.not' : '') + '.') + + details.matcherName + + utils.DIM_COLOR('(') + utils.EXPECTED_COLOR(details.expectation) + utils.DIM_COLOR(')') + + ' failed\n\n'; + + // Sometimes diff is actually expected + received. Turn it into two lines to + // simplify alignment logic. + const diffLines = details.printedDiff?.split('\n'); + if (diffLines?.length === 2) { + details.printedExpected = diffLines[0]; + details.printedReceived = diffLines[1]; + details.printedDiff = undefined; + } + + const align = !details.errorMessage && details.printedExpected?.startsWith('Expected:') + && (!details.printedReceived || details.printedReceived.startsWith('Received:')); + if (details.locator) + message += `Locator: ${align ? ' ' : ''}${details.locator}\n`; + if (details.printedExpected) + message += details.printedExpected + '\n'; + if (details.printedReceived) + message += details.printedReceived + '\n'; + if (details.timedOut && details.timeout) + message += `Timeout: ${align ? ' ' : ''}${details.timeout}ms\n`; + if (details.printedDiff) + message += details.printedDiff + '\n'; + if (details.errorMessage) { + message += details.errorMessage; + if (!details.errorMessage.endsWith('\n')) + message += '\n'; + } + message += callLogText(utils, details.log); + return message; +} + +export const callLogText = (utils: InternalMatcherUtils, log: string[] | undefined) => { + if (!log || !log.some(l => !!l)) + return ''; + return ` +Call log: +${utils.DIM_COLOR(log.join('\n'))} +`; +}; diff --git a/packages/playwright-core/src/server/webkit/protocol.d.ts b/packages/playwright-core/src/server/webkit/protocol.d.ts index d56e9947aab79..d11eea764d3b8 100644 --- a/packages/playwright-core/src/server/webkit/protocol.d.ts +++ b/packages/playwright-core/src/server/webkit/protocol.d.ts @@ -407,7 +407,7 @@ export namespace Protocol { /** * Pseudo-style identifier (see enum PseudoId in RenderStyleConstants.h). */ - export type PseudoId = "first-line"|"first-letter"|"grammar-error"|"highlight"|"marker"|"before"|"after"|"selection"|"backdrop"|"spelling-error"|"target-text"|"checkmark"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"|"-webkit-scrollbar"|"-webkit-resizer"|"-webkit-scrollbar-thumb"|"-webkit-scrollbar-button"|"-webkit-scrollbar-track"|"-webkit-scrollbar-track-piece"|"-webkit-scrollbar-corner"; + export type PseudoId = "first-line"|"first-letter"|"grammar-error"|"highlight"|"marker"|"before"|"after"|"selection"|"backdrop"|"spelling-error"|"target-text"|"view-transition"|"view-transition-group"|"view-transition-image-pair"|"view-transition-old"|"view-transition-new"|"-webkit-scrollbar"|"-webkit-resizer"|"-webkit-scrollbar-thumb"|"-webkit-scrollbar-button"|"-webkit-scrollbar-track"|"-webkit-scrollbar-track-piece"|"-webkit-scrollbar-corner"; /** * Pseudo-style identifier (see enum PseudoId in RenderStyleConstants.h). */ diff --git a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts index 616b5e838b092..4da75fa607c63 100644 --- a/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts +++ b/packages/playwright-core/src/utils/isomorphic/protocolMetainfo.ts @@ -316,5 +316,6 @@ export const methodMetainfo = new Map; + /** + * Returns the current token usage for this agent. + * + * **Usage** + * + * ```js + * const usage = await agent.usage(); + * console.log(`Tokens used: ${usage.inputTokens} in, ${usage.outputTokens} out`); + * ``` + * + */ + usage(): Promise<{ + turns: number; + + inputTokens: number; + + outputTokens: number; + }>; + [Symbol.asyncDispose](): Promise; } diff --git a/packages/playwright/bundles/expect/src/expectBundleImpl.ts b/packages/playwright/bundles/expect/src/expectBundleImpl.ts index cf818e265171f..49e8f0563ac7b 100644 --- a/packages/playwright/bundles/expect/src/expectBundleImpl.ts +++ b/packages/playwright/bundles/expect/src/expectBundleImpl.ts @@ -16,11 +16,3 @@ import expectLibrary from 'expect'; export const expect = expectLibrary; - -export { - EXPECTED_COLOR, - INVERTED_COLOR, - RECEIVED_COLOR, - DIM_COLOR, - printReceived, -} from 'jest-matcher-utils'; diff --git a/packages/playwright/src/common/expectBundle.ts b/packages/playwright/src/common/expectBundle.ts index ebec808537dee..960c55adea79f 100644 --- a/packages/playwright/src/common/expectBundle.ts +++ b/packages/playwright/src/common/expectBundle.ts @@ -15,8 +15,3 @@ */ export const expect: typeof import('../../bundles/expect/node_modules/expect/build').expect = require('./expectBundleImpl').expect; -export const EXPECTED_COLOR: typeof import('../../bundles/expect/node_modules/jest-matcher-utils/build').EXPECTED_COLOR = require('./expectBundleImpl').EXPECTED_COLOR; -export const INVERTED_COLOR: typeof import('../../bundles/expect/node_modules/jest-matcher-utils/build').INVERTED_COLOR = require('./expectBundleImpl').INVERTED_COLOR; -export const RECEIVED_COLOR: typeof import('../../bundles/expect/node_modules/jest-matcher-utils/build').RECEIVED_COLOR = require('./expectBundleImpl').RECEIVED_COLOR; -export const DIM_COLOR: typeof import('../../bundles/expect/node_modules/jest-matcher-utils/build').DIM_COLOR = require('./expectBundleImpl').DIM_COLOR; -export const printReceived: typeof import('../../bundles/expect/node_modules/jest-matcher-utils/build').printReceived = require('./expectBundleImpl').printReceived; diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 90c942f28c3dd..1a52a43897417 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -486,6 +486,10 @@ const playwrightFixtures: Fixtures = ({ await use(agent); + const usage = await agent.usage(); + if (usage.turns > 0) + await testInfoImpl.attach('agent-usage', { contentType: 'application/json', body: Buffer.from(JSON.stringify(usage, null, 2)) }); + if (!resolvedCacheFile || !cacheOutFile) return; if (testInfo.status !== 'passed') diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 248cebe5f528f..02d2da2f0c7af 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -58,10 +58,7 @@ import { import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; import { toHaveScreenshot, toMatchSnapshot } from './toMatchSnapshot'; import { - INVERTED_COLOR, - RECEIVED_COLOR, expect as expectLibrary, - printReceived, } from '../common/expectBundle'; import { currentTestInfo } from '../common/globals'; import { filteredStackTrace } from '../util'; @@ -71,47 +68,6 @@ import type { ExpectMatcherStateInternal } from './matchers'; import type { Expect } from '../../types/test'; import type { TestStepInfoImpl } from '../worker/testInfo'; - -// #region -// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts -/** - * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. - * - * This source code is licensed under the MIT license found here - * https://github.com/facebook/jest/blob/1547740bbc26400d69f4576bf35645163e942829/LICENSE - */ - -// Format substring but do not enclose in double quote marks. -// The replacement is compatible with pretty-format package. -const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&'); - -export const printReceivedStringContainExpectedSubstring = ( - received: string, - start: number, - length: number, // not end -): string => - RECEIVED_COLOR( - '"' + - printSubstring(received.slice(0, start)) + - INVERTED_COLOR(printSubstring(received.slice(start, start + length))) + - printSubstring(received.slice(start + length)) + - '"', - ); - -export const printReceivedStringContainExpectedResult = ( - received: string, - result: RegExpExecArray | null, -): string => - result === null - ? printReceived(received) - : printReceivedStringContainExpectedSubstring( - received, - result.index, - result[0].length, - ); - -// #endregion - type ExpectMessage = string | { message?: string }; function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]): any { @@ -447,7 +403,7 @@ async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, p } } -export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers); +export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers as any); export function mergeExpects(...expects: any[]) { let merged = expect; diff --git a/packages/playwright/src/matchers/matcherHint.ts b/packages/playwright/src/matchers/matcherHint.ts index 7c0b809b3576f..bc7e846babff6 100644 --- a/packages/playwright/src/matchers/matcherHint.ts +++ b/packages/playwright/src/matchers/matcherHint.ts @@ -15,63 +15,8 @@ */ import { stringifyStackFrames } from 'playwright-core/lib/utils'; -import { DIM_COLOR, RECEIVED_COLOR, EXPECTED_COLOR } from '../common/expectBundle'; -import type { ExpectMatcherState } from '../../types/test'; import type { StackFrame } from '@protocol/channels'; -import type { Locator } from 'playwright-core'; - -type MatcherMessageDetails = { - receiver?: string; // Assuming 'locator' when locator is provided, 'page' otherwise. - matcherName: string; - expectation: string; - locator?: Locator; - printedExpected?: string; - printedReceived?: string; - printedDiff?: string; - timedOut?: boolean; - timeout?: number; - errorMessage?: string; - log?: string[]; -}; - -export function formatMatcherMessage(state: ExpectMatcherState, details: MatcherMessageDetails) { - const receiver = details.receiver ?? (details.locator ? 'locator' : 'page'); - let message = DIM_COLOR('expect(') + RECEIVED_COLOR(receiver) - + DIM_COLOR(')' + (state.promise ? '.' + state.promise : '') + (state.isNot ? '.not' : '') + '.') - + details.matcherName - + DIM_COLOR('(') + EXPECTED_COLOR(details.expectation) + DIM_COLOR(')') - + ' failed\n\n'; - - // Sometimes diff is actually expected + received. Turn it into two lines to - // simplify alignment logic. - const diffLines = details.printedDiff?.split('\n'); - if (diffLines?.length === 2) { - details.printedExpected = diffLines[0]; - details.printedReceived = diffLines[1]; - details.printedDiff = undefined; - } - - const align = !details.errorMessage && details.printedExpected?.startsWith('Expected:') - && (!details.printedReceived || details.printedReceived.startsWith('Received:')); - if (details.locator) - message += `Locator: ${align ? ' ' : ''}${String(details.locator)}\n`; - if (details.printedExpected) - message += details.printedExpected + '\n'; - if (details.printedReceived) - message += details.printedReceived + '\n'; - if (details.timedOut && details.timeout) - message += `Timeout: ${align ? ' ' : ''}${details.timeout}ms\n`; - if (details.printedDiff) - message += details.printedDiff + '\n'; - if (details.errorMessage) { - message += details.errorMessage; - if (!details.errorMessage.endsWith('\n')) - message += '\n'; - } - message += callLogText(details.log); - return message; -} export type MatcherResult = { name: string; @@ -111,12 +56,3 @@ export class ExpectError extends Error { export function isJestError(e: unknown): e is JestError { return e instanceof Error && 'matcherResult' in e && !!e.matcherResult; } - -export const callLogText = (log: string[] | undefined) => { - if (!log || !log.some(l => !!l)) - return ''; - return ` -Call log: -${DIM_COLOR(log.join('\n'))} -`; -}; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 03f8b9f4ac3e1..dbde0329e851d 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { asLocatorDescription, constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues, toKebabCase } from 'playwright-core/lib/utils'; +import { asLocatorDescription, constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues, toKebabCase, formatMatcherMessage } from 'playwright-core/lib/utils'; import { colors } from 'playwright-core/lib/utils'; import { expectTypes } from '../util'; @@ -26,15 +26,19 @@ import { toHaveScreenshotStepTitle } from './toMatchSnapshot'; import { takeFirst } from '../common/config'; import { currentTestInfo } from '../common/globals'; import { TestInfoImpl } from '../worker/testInfo'; -import { formatMatcherMessage, MatcherResult } from './matcherHint'; +import { MatcherResult } from './matcherHint'; import type { ExpectMatcherState } from '../../types/test'; import type { TestStepInfoImpl } from '../worker/testInfo'; import type { APIResponse, Locator, Frame, Page } from 'playwright-core'; import type { FrameExpectParams } from 'playwright-core/lib/client/types'; -import type { CSSProperties } from '../../types/test'; +import type { CSSProperties, ExpectMatcherUtils } from '../../types/test'; +import type { InternalMatcherUtils } from 'playwright-core/lib/utils'; -export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl }; +export type ExpectMatcherStateInternal = Omit & { + _stepInfo?: TestStepInfoImpl; + utils: ExpectMatcherUtils & InternalMatcherUtils; +}; export interface LocatorEx extends Locator { _selector: string; @@ -50,7 +54,7 @@ interface APIResponseEx extends APIResponse { } export function toBeAttached( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { attached?: boolean, timeout?: number }, ) { @@ -63,7 +67,7 @@ export function toBeAttached( } export function toBeChecked( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { checked?: boolean, indeterminate?: boolean, timeout?: number }, ) { @@ -88,7 +92,7 @@ export function toBeChecked( } export function toBeDisabled( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { timeout?: number }, ) { @@ -98,7 +102,7 @@ export function toBeDisabled( } export function toBeEditable( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { editable?: boolean, timeout?: number }, ) { @@ -111,7 +115,7 @@ export function toBeEditable( } export function toBeEmpty( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { timeout?: number }, ) { @@ -121,7 +125,7 @@ export function toBeEmpty( } export function toBeEnabled( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { enabled?: boolean, timeout?: number }, ) { @@ -134,7 +138,7 @@ export function toBeEnabled( } export function toBeFocused( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { timeout?: number }, ) { @@ -144,7 +148,7 @@ export function toBeFocused( } export function toBeHidden( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { timeout?: number }, ) { @@ -154,7 +158,7 @@ export function toBeHidden( } export function toBeVisible( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { visible?: boolean, timeout?: number }, ) { @@ -167,7 +171,7 @@ export function toBeVisible( } export function toBeInViewport( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, options?: { timeout?: number, ratio?: number }, ) { @@ -177,7 +181,7 @@ export function toBeInViewport( } export function toContainText( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp | (string | RegExp)[], options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean } = {}, @@ -196,7 +200,7 @@ export function toContainText( } export function toHaveAccessibleDescription( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp, options?: { timeout?: number, ignoreCase?: boolean }, @@ -208,7 +212,7 @@ export function toHaveAccessibleDescription( } export function toHaveAccessibleName( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp, options?: { timeout?: number, ignoreCase?: boolean }, @@ -220,7 +224,7 @@ export function toHaveAccessibleName( } export function toHaveAccessibleErrorMessage( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp, options?: { timeout?: number; ignoreCase?: boolean }, @@ -232,7 +236,7 @@ export function toHaveAccessibleErrorMessage( } export function toHaveAttribute( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, name: string, expected: string | RegExp | undefined | { timeout?: number }, @@ -257,7 +261,7 @@ export function toHaveAttribute( } export function toHaveClass( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp | (string | RegExp)[], options?: { timeout?: number }, @@ -276,7 +280,7 @@ export function toHaveClass( } export function toContainClass( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | string[], options?: { timeout?: number }, @@ -299,7 +303,7 @@ export function toContainClass( } export function toHaveCount( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: number, options?: { timeout?: number }, @@ -309,10 +313,10 @@ export function toHaveCount( }, expected, options); } -export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, name: string, expected: string | RegExp, options?: { timeout?: number }): Promise>; -export function toHaveCSS(this: ExpectMatcherState, locator: LocatorEx, styles: CSSProperties, options?: { timeout?: number }): Promise>; +export function toHaveCSS(this: ExpectMatcherStateInternal, locator: LocatorEx, name: string, expected: string | RegExp, options?: { timeout?: number }): Promise>; +export function toHaveCSS(this: ExpectMatcherStateInternal, locator: LocatorEx, styles: CSSProperties, options?: { timeout?: number }): Promise>; export function toHaveCSS( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, nameOrStyles: string | CSSProperties, expectedOrOptions?: (string | RegExp) | { timeout?: number }, @@ -347,7 +351,7 @@ export function toHaveCSS( } export function toHaveId( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp, options?: { timeout?: number }, @@ -359,7 +363,7 @@ export function toHaveId( } export function toHaveJSProperty( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, name: string, expected: any, @@ -371,7 +375,7 @@ export function toHaveJSProperty( } export function toHaveRole( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string, options?: { timeout?: number, ignoreCase?: boolean }, @@ -385,7 +389,7 @@ export function toHaveRole( } export function toHaveText( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp | (string | RegExp)[], options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean } = {}, @@ -404,7 +408,7 @@ export function toHaveText( } export function toHaveValue( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: string | RegExp, options?: { timeout?: number }, @@ -416,7 +420,7 @@ export function toHaveValue( } export function toHaveValues( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expected: (string | RegExp)[], options?: { timeout?: number }, @@ -428,7 +432,7 @@ export function toHaveValues( } export function toHaveTitle( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, page: Page, expected: string | RegExp, options: { timeout?: number } = {}, @@ -440,7 +444,7 @@ export function toHaveTitle( } export function toHaveURL( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, page: Page, expected: string | RegExp | ((url: URL) => boolean), options?: { ignoreCase?: boolean; timeout?: number }, @@ -458,7 +462,7 @@ export function toHaveURL( } export async function toBeOK( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, response: APIResponseEx ) { const matcherName = 'toBeOK'; @@ -471,7 +475,9 @@ export async function toBeOK( isTextEncoding ? response.text() : null ]) : []; - const message = () => formatMatcherMessage(this, { + const message = () => formatMatcherMessage(this.utils, { + isNot: this.isNot, + promise: this.promise, matcherName, receiver: 'response', expectation: '', diff --git a/packages/playwright/src/matchers/toBeTruthy.ts b/packages/playwright/src/matchers/toBeTruthy.ts index 0802277421a73..3f4dc9c71e551 100644 --- a/packages/playwright/src/matchers/toBeTruthy.ts +++ b/packages/playwright/src/matchers/toBeTruthy.ts @@ -14,15 +14,16 @@ * limitations under the License. */ +import { formatMatcherMessage } from 'playwright-core/lib/utils'; + import { expectTypes } from '../util'; -import { formatMatcherMessage } from './matcherHint'; import type { MatcherResult } from './matcherHint'; -import type { ExpectMatcherState } from '../../types/test'; import type { Locator } from 'playwright-core'; +import type { ExpectMatcherStateInternal } from './matchers'; export async function toBeTruthy( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, matcherName: string, locator: Locator, receiverType: string, @@ -56,10 +57,12 @@ export async function toBeTruthy( printedReceived = errorMessage ? '' : `Received: ${received}`; } const message = () => { - return formatMatcherMessage(this, { + return formatMatcherMessage(this.utils, { + isNot: this.isNot, + promise: this.promise, matcherName, expectation: arg, - locator, + locator: locator.toString(), timeout, timedOut, printedExpected, diff --git a/packages/playwright/src/matchers/toEqual.ts b/packages/playwright/src/matchers/toEqual.ts index e22f8a9c657d3..e158f618029b1 100644 --- a/packages/playwright/src/matchers/toEqual.ts +++ b/packages/playwright/src/matchers/toEqual.ts @@ -14,21 +14,20 @@ * limitations under the License. */ -import { isRegExp } from 'playwright-core/lib/utils'; +import { formatMatcherMessage, isRegExp } from 'playwright-core/lib/utils'; import { expectTypes } from '../util'; -import { formatMatcherMessage } from './matcherHint'; import type { MatcherResult } from './matcherHint'; -import type { ExpectMatcherState } from '../../types/test'; import type { Locator } from 'playwright-core'; +import type { ExpectMatcherStateInternal } from './matchers'; // Omit colon and one or more spaces, so can call getLabelPrinter. const EXPECTED_LABEL = 'Expected'; const RECEIVED_LABEL = 'Received'; export async function toEqual( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, matcherName: string, locator: Locator, receiverType: string, @@ -84,10 +83,12 @@ export async function toEqual( ); } const message = () => { - return formatMatcherMessage(this, { + return formatMatcherMessage(this.utils, { + isNot: this.isNot, + promise: this.promise, matcherName, expectation: 'expected', - locator, + locator: locator.toString(), timeout, timedOut, printedExpected, diff --git a/packages/playwright/src/matchers/toHaveURL.ts b/packages/playwright/src/matchers/toHaveURL.ts index f3c4ded541090..959eca13c1747 100644 --- a/packages/playwright/src/matchers/toHaveURL.ts +++ b/packages/playwright/src/matchers/toHaveURL.ts @@ -14,18 +14,14 @@ * limitations under the License. */ -import { urlMatches } from 'playwright-core/lib/utils'; - -import { printReceivedStringContainExpectedResult } from './expect'; -import { formatMatcherMessage } from './matcherHint'; -import { printReceived } from '../common/expectBundle'; +import { formatMatcherMessage, printReceivedStringContainExpectedResult, urlMatches } from 'playwright-core/lib/utils'; import type { MatcherResult } from './matcherHint'; -import type { ExpectMatcherState } from '../../types/test'; import type { Page } from 'playwright-core'; +import type { ExpectMatcherStateInternal } from './matchers'; export async function toHaveURLWithPredicate( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, page: Page, expected: (url: URL) => boolean, options?: { ignoreCase?: boolean; timeout?: number }, @@ -85,7 +81,7 @@ export async function toHaveURLWithPredicate( } function toHaveURLMessage( - state: ExpectMatcherState, + state: ExpectMatcherStateInternal, matcherName: string, expected: Function, received: string | undefined, @@ -100,11 +96,11 @@ function toHaveURLMessage( let printedDiff: string | undefined; if (typeof expected === 'function') { printedExpected = `Expected: predicate to ${!state.isNot ? 'succeed' : 'fail'}`; - printedReceived = `Received: ${printReceived(receivedString)}`; + printedReceived = `Received: ${state.utils.printReceived(receivedString)}`; } else { if (pass) { printedExpected = `Expected pattern: not ${state.utils.printExpected(expected)}`; - const formattedReceived = printReceivedStringContainExpectedResult(receivedString, null); + const formattedReceived = printReceivedStringContainExpectedResult(state.utils, receivedString, null); printedReceived = `Received string: ${formattedReceived}`; } else { const labelExpected = `Expected ${typeof expected === 'string' ? 'string' : 'pattern'}`; @@ -112,7 +108,9 @@ function toHaveURLMessage( } } - return formatMatcherMessage(state, { + return formatMatcherMessage(state.utils, { + isNot: state.isNot, + promise: state.promise, matcherName, expectation: 'expected', timeout, diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts index 1075d8d86d305..1f11d8ee3f0f4 100644 --- a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -18,16 +18,13 @@ import fs from 'fs'; import path from 'path'; -import { escapeTemplateString, isString } from 'playwright-core/lib/utils'; +import { formatMatcherMessage, escapeTemplateString, isString, printReceivedStringContainExpectedSubstring } from 'playwright-core/lib/utils'; -import { formatMatcherMessage } from './matcherHint'; import { fileExistsAsync } from '../util'; -import { printReceivedStringContainExpectedSubstring } from './expect'; import { currentTestInfo } from '../common/globals'; import type { MatcherResult } from './matcherHint'; -import type { LocatorEx } from './matchers'; -import type { ExpectMatcherState } from '../../types/test'; +import type { ExpectMatcherStateInternal, LocatorEx } from './matchers'; import type { MatcherReceived } from '@injected/ariaSnapshot'; @@ -38,7 +35,7 @@ type ToMatchAriaSnapshotExpected = { } | string; export async function toMatchAriaSnapshot( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, locator: LocatorEx, expectedParam?: ToMatchAriaSnapshotExpected, options: { timeout?: number } = {}, @@ -93,16 +90,18 @@ export async function toMatchAriaSnapshot( if (errorMessage) { printedExpected = `Expected: ${this.isNot ? 'not ' : ''}${this.utils.printExpected(expected)}`; } else if (pass) { - const receivedString = printReceivedStringContainExpectedSubstring(typedReceived.raw, typedReceived.raw.indexOf(expected), expected.length); + const receivedString = printReceivedStringContainExpectedSubstring(this.utils, typedReceived.raw, typedReceived.raw.indexOf(expected), expected.length); printedExpected = `Expected: not ${this.utils.printExpected(expected)}`; printedReceived = `Received: ${receivedString}`; } else { printedDiff = this.utils.printDiffOrStringify(expected, typedReceived.raw, 'Expected', 'Received', false); } - return formatMatcherMessage(this, { + return formatMatcherMessage(this.utils, { + isNot: this.isNot, + promise: this.promise, matcherName, expectation: 'expected', - locator, + locator: locator.toString(), timeout, timedOut, printedExpected, diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 3f36ea64457f0..661bf88d0772b 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -17,12 +17,11 @@ import fs from 'fs'; import path from 'path'; -import { compareBuffersOrStrings, getComparator, isString } from 'playwright-core/lib/utils'; +import { callLogText, formatMatcherMessage, compareBuffersOrStrings, getComparator, isString } from 'playwright-core/lib/utils'; import { colors } from 'playwright-core/lib/utils'; import { mime } from 'playwright-core/lib/utilsBundle'; import { addSuffixToFilePath, expectTypes } from '../util'; -import { callLogText, formatMatcherMessage } from './matcherHint'; import { currentTestInfo } from '../common/globals'; import type { MatcherResult } from './matcherHint'; @@ -82,8 +81,10 @@ class SnapshotHelper { readonly options: Omit & { comparator?: string }; readonly matcherName: string; readonly locator: Locator | undefined; + readonly state: ExpectMatcherStateInternal; constructor( + state: ExpectMatcherStateInternal, testInfo: TestInfoImpl, matcherName: 'toMatchSnapshot' | 'toHaveScreenshot', locator: Locator | undefined, @@ -141,6 +142,7 @@ class SnapshotHelper { this.comparator = getComparator(this.mimeType); this.testInfo = testInfo; + this.state = state; this.kind = this.mimeType.startsWith('image/') ? 'Screenshot' : 'Snapshot'; } @@ -234,7 +236,7 @@ class SnapshotHelper { } if (log?.length) - output.push(callLogText(log)); + output.push(callLogText(this.state.utils, log)); else output.push(''); @@ -263,7 +265,7 @@ export function toMatchSnapshot( const configOptions = testInfo._projectInternal.expect?.toMatchSnapshot || {}; const helper = new SnapshotHelper( - testInfo, 'toMatchSnapshot', undefined, '.' + determineFileExtension(received), + this, testInfo, 'toMatchSnapshot', undefined, '.' + determineFileExtension(received), configOptions, nameOrOptions, optOptions); if (this.isNot) { @@ -301,7 +303,7 @@ export function toMatchSnapshot( if (!result) return helper.handleMatching(); - const header = formatMatcherMessage(this, { matcherName: 'toMatchSnapshot', receiver: isString(received) ? 'string' : 'Buffer', expectation: 'expected' }); + const header = formatMatcherMessage(this.utils, { promise: this.promise, isNot: this.isNot, matcherName: 'toMatchSnapshot', receiver: isString(received) ? 'string' : 'Buffer', expectation: 'expected' }); return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined, this._stepInfo); } @@ -333,7 +335,7 @@ export async function toHaveScreenshot( expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); const [page, locator] = pageOrLocator.constructor.name === 'Page' ? [(pageOrLocator as PageEx), undefined] : [(pageOrLocator as Locator).page() as PageEx, pageOrLocator as Locator]; const configOptions = testInfo._projectInternal.expect?.toHaveScreenshot || {}; - const helper = new SnapshotHelper(testInfo, 'toHaveScreenshot', locator, undefined, configOptions, nameOrOptions, optOptions); + const helper = new SnapshotHelper(this, testInfo, 'toHaveScreenshot', locator, undefined, configOptions, nameOrOptions, optOptions); if (!helper.expectedPath.toLowerCase().endsWith('.png')) throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`); expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); @@ -381,7 +383,7 @@ export async function toHaveScreenshot( // We tried re-generating new snapshot but failed. // This can be due to e.g. spinning animation, so we want to show it as a diff. if (errorMessage) { - const header = formatMatcherMessage(this, { matcherName: 'toHaveScreenshot', locator, expectation: 'expected', timeout, timedOut }); + const header = formatMatcherMessage(this.utils, { promise: this.promise, isNot: this.isNot, matcherName: 'toHaveScreenshot', locator: locator?.toString(), expectation: 'expected', timeout, timedOut }); return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log, this._stepInfo); } @@ -416,12 +418,12 @@ export async function toHaveScreenshot( if (helper.updateSnapshots === 'changed' || helper.updateSnapshots === 'all') { if (actual) return writeFiles(actual); - let header = formatMatcherMessage(this, { matcherName: 'toHaveScreenshot', locator, expectation: 'expected', timeout, timedOut }); + let header = formatMatcherMessage(this.utils, { promise: this.promise, isNot: this.isNot, matcherName: 'toHaveScreenshot', locator: locator?.toString(), expectation: 'expected', timeout, timedOut }); header += ' Failed to re-generate expected.\n'; return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log, this._stepInfo); } - const header = formatMatcherMessage(this, { matcherName: 'toHaveScreenshot', locator, expectation: 'expected', timeout, timedOut }); + const header = formatMatcherMessage(this.utils, { promise: this.promise, isNot: this.isNot, matcherName: 'toHaveScreenshot', locator: locator?.toString(), expectation: 'expected', timeout, timedOut }); return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log, this._stepInfo); } diff --git a/packages/playwright/src/matchers/toMatchText.ts b/packages/playwright/src/matchers/toMatchText.ts index 508702bcd01d1..85410d734bca4 100644 --- a/packages/playwright/src/matchers/toMatchText.ts +++ b/packages/playwright/src/matchers/toMatchText.ts @@ -14,21 +14,16 @@ * limitations under the License. */ +import { formatMatcherMessage, printReceivedStringContainExpectedResult, printReceivedStringContainExpectedSubstring } from 'playwright-core/lib/utils'; import { expectTypes } from '../util'; -import { - printReceivedStringContainExpectedResult, - printReceivedStringContainExpectedSubstring -} from './expect'; -import { formatMatcherMessage } from './matcherHint'; -import { EXPECTED_COLOR } from '../common/expectBundle'; import type { MatcherResult } from './matcherHint'; -import type { ExpectMatcherState } from '../../types/test'; import type { Page, Locator } from 'playwright-core'; +import type { ExpectMatcherStateInternal } from './matchers'; export async function toMatchText( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, matcherName: string, receiver: Locator | Page, receiverType: 'Locator' | 'Page', @@ -43,8 +38,8 @@ export async function toMatchText( !(typeof expected === 'string') && !(expected && typeof expected.test === 'function') ) { - const errorMessage = `Error: ${EXPECTED_COLOR('expected')} value must be a string or regular expression\n${this.utils.printWithType('Expected', expected, this.utils.printExpected)}`; - throw new Error(formatMatcherMessage(this, { locator, matcherName, expectation: 'expected', errorMessage })); + const errorMessage = `Error: ${this.utils.EXPECTED_COLOR('expected')} value must be a string or regular expression\n${this.utils.printWithType('Expected', expected, this.utils.printExpected)}`; + throw new Error(formatMatcherMessage(this.utils, { promise: this.promise, isNot: this.isNot, locator: locator?.toString(), matcherName, expectation: 'expected', errorMessage })); } const timeout = options.timeout ?? this.timeout; @@ -70,13 +65,13 @@ export async function toMatchText( if (typeof expected === 'string') { printedExpected = `Expected${expectedSuffix}: not ${this.utils.printExpected(expected)}`; if (!errorMessage) { - const formattedReceived = printReceivedStringContainExpectedSubstring(receivedString, receivedString.indexOf(expected), expected.length); + const formattedReceived = printReceivedStringContainExpectedSubstring(this.utils, receivedString, receivedString.indexOf(expected), expected.length); printedReceived = `Received${receivedSuffix}: ${formattedReceived}`; } } else { printedExpected = `Expected${expectedSuffix}: not ${this.utils.printExpected(expected)}`; if (!errorMessage) { - const formattedReceived = printReceivedStringContainExpectedResult(receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null); + const formattedReceived = printReceivedStringContainExpectedResult(this.utils, receivedString, typeof expected.exec === 'function' ? expected.exec(receivedString) : null); printedReceived = `Received${receivedSuffix}: ${formattedReceived}`; } } @@ -88,10 +83,12 @@ export async function toMatchText( } const message = () => { - return formatMatcherMessage(this, { + return formatMatcherMessage(this.utils, { + promise: this.promise, + isNot: this.isNot, matcherName, expectation: 'expected', - locator, + locator: locator?.toString(), timeout, timedOut, printedExpected, diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 600a3ab63edb5..38e0009d0d806 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -5103,6 +5103,7 @@ export interface PageAgentChannel extends PageAgentEventTarget, EventTargetChann expect(params: PageAgentExpectParams, progress?: Progress): Promise; extract(params: PageAgentExtractParams, progress?: Progress): Promise; dispose(params?: PageAgentDisposeParams, progress?: Progress): Promise; + usage(params?: PageAgentUsageParams, progress?: Progress): Promise; } export type PageAgentTurnEvent = { role: string, @@ -5165,6 +5166,11 @@ export type PageAgentExtractResult = { export type PageAgentDisposeParams = {}; export type PageAgentDisposeOptions = {}; export type PageAgentDisposeResult = void; +export type PageAgentUsageParams = {}; +export type PageAgentUsageOptions = {}; +export type PageAgentUsageResult = { + usage: AgentUsage, +}; export interface PageAgentEvents { 'turn': PageAgentTurnEvent; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 0d11c2d567b6d..edce8f286f432 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -4352,6 +4352,12 @@ PageAgent: dispose: internal: true + usage: + title: 'Get agent usage' + group: configuration + returns: + usage: AgentUsage + events: turn: diff --git a/tests/bidi/expectations/moz-firefox-nightly-library.txt b/tests/bidi/expectations/moz-firefox-nightly-library.txt index 246e6ddeec873..69eb69538a953 100644 --- a/tests/bidi/expectations/moz-firefox-nightly-library.txt +++ b/tests/bidi/expectations/moz-firefox-nightly-library.txt @@ -93,7 +93,6 @@ library/defaultbrowsercontext-2.spec.ts › should have passed URL when launchin library/defaultbrowsercontext-2.spec.ts › should support colorScheme option [fail] library/defaultbrowsercontext-2.spec.ts › should support contrast option [fail] library/defaultbrowsercontext-2.spec.ts › should support forcedColors option [fail] -library/defaultbrowsercontext-2.spec.ts › should support geolocation and permissions options [fail] library/defaultbrowsercontext-2.spec.ts › should support hasTouch option [fail] library/defaultbrowsercontext-2.spec.ts › should support reducedMotion option [fail] library/download.spec.ts › download event › should be able to cancel pending downloads [timeout] @@ -131,11 +130,6 @@ library/emulation-focus.spec.ts › should focus with more than one page/context library/emulation-focus.spec.ts › should think that all pages are focused @smoke [fail] library/fetch-proxy.spec.ts › context request should pick up proxy credentials [timeout] library/firefox/launcher.spec.ts › should support custom firefox policies [fail] -library/geolocation.spec.ts › should isolate contexts [fail] -library/geolocation.spec.ts › should use context options [fail] -library/geolocation.spec.ts › should use context options for popup [fail] -library/geolocation.spec.ts › should work @smoke [fail] -library/geolocation.spec.ts › watchPosition should be notified [fail] library/har.spec.ts › should have connection details [fail] library/har.spec.ts › should have connection details for failed requests [fail] library/har.spec.ts › should have connection details for redirects [fail] @@ -168,12 +162,8 @@ library/page-event-crash.spec.ts › should cancel waitForEvent when page crashe library/page-event-crash.spec.ts › should emit crash event when page crashes [timeout] library/page-event-crash.spec.ts › should throw on any action after page crashes [timeout] library/permissions.spec.ts › local network request is allowed from public origin [timeout] -library/permissions.spec.ts › permissions › should accumulate when adding [fail] -library/permissions.spec.ts › permissions › should clear permissions [fail] library/permissions.spec.ts › permissions › should deny permission when not listed [fail] library/permissions.spec.ts › permissions › should fail when bad permission is given [fail] -library/permissions.spec.ts › permissions › should grant permission when creating context [fail] -library/permissions.spec.ts › permissions › should grant permission when listed for all domains [fail] library/permissions.spec.ts › permissions › should isolate permissions between browser contexts [fail] library/permissions.spec.ts › permissions › should trigger permission onchange [fail] library/permissions.spec.ts › should support clipboard read [fail] diff --git a/tests/library/agent-helpers.ts b/tests/library/agent-helpers.ts index 42d11e58a0b5f..af0920aeb8847 100644 --- a/tests/library/agent-helpers.ts +++ b/tests/library/agent-helpers.ts @@ -20,7 +20,7 @@ import path from 'path'; import { browserTest as test } from '../config/browserTest'; import type { BrowserContext, Page, PageAgent } from '@playwright/test'; -function cacheFile() { +export function cacheFile() { return test.info().outputPath('agent-cache.json'); } diff --git a/tests/library/agent-perform.spec.ts b/tests/library/agent-perform.spec.ts index c6020f58b777e..b57cbf1219e90 100644 --- a/tests/library/agent-perform.spec.ts +++ b/tests/library/agent-perform.spec.ts @@ -14,10 +14,11 @@ * limitations under the License. */ +import fs from 'fs'; import { z } from 'zod'; import { browserTest as test, expect } from '../config/browserTest'; -import { run, generateAgent, cacheObject, runAgent } from './agent-helpers'; +import { run, generateAgent, cacheObject, runAgent, cacheFile } from './agent-helpers'; // LOWIRE_NO_CACHE=1 to generate api caches // LOWIRE_FORCE_CACHE=1 to force api caches @@ -154,3 +155,17 @@ test('perform run timeout', async ({ context }) => { expect(error.message).toContain(`waiting for getByRole('button', { name: 'Fox' })`); } }); + +test('invalid cache file throws error', async ({ context }) => { + await fs.promises.writeFile(cacheFile(), JSON.stringify({ + 'some key': { + actions: [{ + method: 'invalid-method', + }], + }, + })); + const { agent } = await runAgent(context); + await expect(() => agent.perform('click the Test button')).rejects.toThrowError( + /.*Failed to parse cache file .*: Invalid discriminator value*/ + ); +});