diff --git a/docs/src/api/class-apirequest.md b/docs/src/api/class-apirequest.md index 19e884d9f5..feea5b8378 100644 --- a/docs/src/api/class-apirequest.md +++ b/docs/src/api/class-apirequest.md @@ -80,7 +80,9 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Populates context with given storage state. This option can be used to initialize context with logged-in information obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the diff --git a/docs/src/api/class-apirequestcontext.md b/docs/src/api/class-apirequestcontext.md index 3cbc9c09cf..b8c179de4f 100644 --- a/docs/src/api/class-apirequestcontext.md +++ b/docs/src/api/class-apirequestcontext.md @@ -896,7 +896,9 @@ context cookies from the response. The method will automatically follow redirect - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor. diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index cae2c0d139..0da156b664 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -1527,7 +1527,9 @@ Whether to emulate network being offline for the browser context. - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9fe3d1b53a..f2d8734336 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -280,7 +280,9 @@ Specify environment variables that will be visible to the browser. Defaults to ` - `multiEntry` <[boolean]> - `records` <[Array]<[Object]>> - `key` ?<[Object]> + - `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types. - `value` <[Object]> + - `valueEncoded` ?<[Object]> if `value` is not JSON-serializable, this contains an encoded version that preserves types. Learn more about [storage state and auth](../auth.md). diff --git a/docs/src/test-api/class-testproject.md b/docs/src/test-api/class-testproject.md index d9814708ef..17fecf6930 100644 --- a/docs/src/test-api/class-testproject.md +++ b/docs/src/test-api/class-testproject.md @@ -183,6 +183,10 @@ Metadata that will be put directly to the test report serialized as JSON. Project name is visible in the report and during test execution. +:::warning +Playwright executes the configuration file multiple times. Do not dynamically produce non-stable values in your configuration. +::: + ## property: TestProject.snapshotDir * since: v1.10 - type: ?<[string]> diff --git a/docs/src/test-api/class-teststepinfo.md b/docs/src/test-api/class-teststepinfo.md index a453d212c8..f4bba6232b 100644 --- a/docs/src/test-api/class-teststepinfo.md +++ b/docs/src/test-api/class-teststepinfo.md @@ -16,6 +16,70 @@ test('basic test', async ({ page, browserName }, TestStepInfo) => { }); ``` +## async method: TestStepInfo.attach +* since: v1.51 + +Attach a value or a file from disk to the current test step. Some reporters show test step attachments. Either [`option: path`] or [`option: body`] must be specified, but not both. Calling this method will attribute the attachment to the step, as opposed to [`method: TestInfo.attach`] which stores all attachments at the test level. + +For example, you can attach a screenshot to the test step: + +```js +import { test, expect } from '@playwright/test'; + +test('basic test', async ({ page }) => { + await page.goto('https://playwright.dev'); + await test.step('check page rendering', async step => { + const screenshot = await page.screenshot(); + await step.attach('screenshot', { body: screenshot, contentType: 'image/png' }); + }); +}); +``` + +Or you can attach files returned by your APIs: + +```js +import { test, expect } from '@playwright/test'; +import { download } from './my-custom-helpers'; + +test('basic test', async ({}) => { + await test.step('check download behavior', async step => { + const tmpPath = await download('a'); + await step.attach('downloaded', { path: tmpPath }); + }); +}); +``` + +:::note +[`method: TestStepInfo.attach`] automatically takes care of copying attached files to a +location that is accessible to reporters. You can safely remove the attachment +after awaiting the attach call. +::: + +### param: TestStepInfo.attach.name +* since: v1.51 +- `name` <[string]> + +Attachment name. The name will also be sanitized and used as the prefix of file name +when saving to disk. + +### option: TestStepInfo.attach.body +* since: v1.51 +- `body` <[string]|[Buffer]> + +Attachment body. Mutually exclusive with [`option: path`]. + +### option: TestStepInfo.attach.contentType +* since: v1.51 +- `contentType` <[string]> + +Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`. If omitted, content type is inferred based on the [`option: path`], or defaults to `text/plain` for [string] attachments and `application/octet-stream` for [Buffer] attachments. + +### option: TestStepInfo.attach.path +* since: v1.51 +- `path` <[string]> + +Path on the filesystem to the attached file. Mutually exclusive with [`option: body`]. + ## method: TestStepInfo.skip#1 * since: v1.51 diff --git a/packages/playwright-core/browsers.json b/packages/playwright-core/browsers.json index 2ea5fdc437..07e34d3fcd 100644 --- a/packages/playwright-core/browsers.json +++ b/packages/playwright-core/browsers.json @@ -27,7 +27,7 @@ }, { "name": "webkit", - "revision": "2127", + "revision": "2130", "installByDefault": true, "revisionOverrides": { "debian11-x64": "2105", diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 8981df6f4a..35832219ee 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -152,7 +152,9 @@ scheme.IndexedDBDatabase = tObject({ keyPathArray: tOptional(tArray(tString)), records: tArray(tObject({ key: tOptional(tAny), - value: tAny, + keyEncoded: tOptional(tAny), + value: tOptional(tAny), + valueEncoded: tOptional(tAny), })), indexes: tArray(tObject({ name: tString, diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 8de81bb1ee..d75be1c34d 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -44,6 +44,7 @@ import { Clock } from './clock'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import { RecorderApp } from './recorder/recorderApp'; import * as storageScript from './storageScript'; +import * as utilityScriptSerializers from './isomorphic/utilityScriptSerializers'; export abstract class BrowserContext extends SdkObject { static Events = { @@ -514,13 +515,15 @@ export abstract class BrowserContext extends SdkObject { }; const originsToSave = new Set(this._origins); + const collectScript = `(${storageScript.collect})((${utilityScriptSerializers.source})(), ${this._browser.options.name === 'firefox'})`; + // First try collecting storage stage from existing pages. for (const page of this.pages()) { const origin = page.mainFrame().origin(); if (!origin || !originsToSave.has(origin)) continue; try { - const storage: storageScript.Storage = await page.mainFrame().nonStallingEvaluateInExistingContext(`(${storageScript.collect})()`, 'utility'); + const storage: storageScript.Storage = await page.mainFrame().nonStallingEvaluateInExistingContext(collectScript, 'utility'); if (storage.localStorage.length || storage.indexedDB.length) result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); originsToSave.delete(origin); @@ -540,7 +543,7 @@ export abstract class BrowserContext extends SdkObject { for (const origin of originsToSave) { const frame = page.mainFrame(); await frame.goto(internalMetadata, origin); - const storage: Awaited> = await frame.evaluateExpression(`(${storageScript.collect})()`, { world: 'utility' }); + const storage: storageScript.Storage = await frame.evaluateExpression(collectScript, { world: 'utility' }); if (storage.localStorage.length || storage.indexedDB.length) result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); } @@ -605,7 +608,7 @@ export abstract class BrowserContext extends SdkObject { for (const originState of state.origins) { const frame = page.mainFrame(); await frame.goto(metadata, originState.origin); - await frame.evaluateExpression(storageScript.restore.toString(), { isFunction: true, world: 'utility' }, originState); + await frame.evaluateExpression(`(${storageScript.restore})(${JSON.stringify(originState)}, (${utilityScriptSerializers.source})())`, { world: 'utility' }); } await page.close(internalMetadata); } diff --git a/packages/playwright-core/src/server/storageScript.ts b/packages/playwright-core/src/server/storageScript.ts index acde6b54f7..517a1529d3 100644 --- a/packages/playwright-core/src/server/storageScript.ts +++ b/packages/playwright-core/src/server/storageScript.ts @@ -15,10 +15,11 @@ */ import type * as channels from '@protocol/channels'; +import type { source } from './isomorphic/utilityScriptSerializers'; export type Storage = Omit; -export async function collect(): Promise { +export async function collect(serializers: ReturnType, isFirefox: boolean): Promise { const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => { if (!dbInfo.name) throw new Error('Database name is empty'); @@ -32,6 +33,39 @@ export async function collect(): Promise { }); } + function isPlainObject(v: any) { + const ctor = v?.constructor; + if (isFirefox) { + const constructorImpl = ctor?.toString(); + if (constructorImpl.startsWith('function Object() {') && constructorImpl.includes('[native code]')) + return true; + } + + return ctor === Object; + } + + function trySerialize(value: any): { trivial?: any, encoded?: any } { + let trivial = true; + const encoded = serializers.serializeAsCallArgument(value, v => { + const isTrivial = ( + isPlainObject(v) + || Array.isArray(v) + || typeof v === 'string' + || typeof v === 'number' + || typeof v === 'boolean' + || Object.is(v, null) + ); + + if (!isTrivial) + trivial = false; + + return { fallThrough: v }; + }); + if (trivial) + return { trivial: value }; + return { encoded }; + } + const db = await idbRequestToPromise(indexedDB.open(dbInfo.name)); const transaction = db.transaction(db.objectStoreNames, 'readonly'); const stores = await Promise.all([...db.objectStoreNames].map(async storeName => { @@ -39,10 +73,24 @@ export async function collect(): Promise { const keys = await idbRequestToPromise(objectStore.getAllKeys()); const records = await Promise.all(keys.map(async key => { - return { - key: objectStore.keyPath === null ? key : undefined, - value: await idbRequestToPromise(objectStore.get(key)) - }; + const record: channels.OriginStorage['indexedDB'][0]['stores'][0]['records'][0] = {}; + + if (objectStore.keyPath === null) { + const { encoded, trivial } = trySerialize(key); + if (trivial) + record.key = trivial; + else + record.keyEncoded = encoded; + } + + const value = await idbRequestToPromise(objectStore.get(key)); + const { encoded, trivial } = trySerialize(value); + if (trivial) + record.value = trivial; + else + record.valueEncoded = encoded; + + return record; })); const indexes = [...objectStore.indexNames].map(indexName => { @@ -81,7 +129,7 @@ export async function collect(): Promise { }; } -export async function restore(originState: channels.SetOriginStorage) { +export async function restore(originState: channels.SetOriginStorage, serializers: ReturnType) { for (const { name, value } of (originState.localStorage || [])) localStorage.setItem(name, value); @@ -111,8 +159,8 @@ export async function restore(originState: channels.SetOriginStorage) { await Promise.all(store.records.map(async record => { await idbRequestToPromise( objectStore.add( - record.value, - objectStore.keyPath === null ? record.key : undefined + record.value ?? serializers.parseEvaluationResultValue(record.valueEncoded), + record.key ?? serializers.parseEvaluationResultValue(record.keyEncoded), ) ); })); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 4e86e5b58b..6f7c35e361 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -9338,7 +9338,17 @@ export interface BrowserContext { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -10147,7 +10157,17 @@ export interface Browser { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -17725,7 +17745,17 @@ export interface APIRequest { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -18568,7 +18598,17 @@ export interface APIRequestContext { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; @@ -22454,7 +22494,17 @@ export interface BrowserContextOptions { records: Array<{ key?: Object; + /** + * if `key` is not JSON-serializable, this contains an encoded version that preserves types. + */ + keyEncoded?: Object; + value: Object; + + /** + * if `value` is not JSON-serializable, this contains an encoded version that preserves types. + */ + valueEncoded?: Object; }>; }>; }>; diff --git a/packages/playwright/bundles/babel/src/babelBundleImpl.ts b/packages/playwright/bundles/babel/src/babelBundleImpl.ts index 54822134ea..d58554b44b 100644 --- a/packages/playwright/bundles/babel/src/babelBundleImpl.ts +++ b/packages/playwright/bundles/babel/src/babelBundleImpl.ts @@ -118,15 +118,15 @@ function isTypeScript(filename: string) { return filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts'); } -export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult { +export function babelTransform(code: string, filename: string, isModule: boolean, pluginsPrologue: [string, any?][], pluginsEpilogue: [string, any?][]): BabelFileResult | null { if (isTransforming) - return {}; + return null; // Prevent reentry while requiring plugins lazily. isTransforming = true; try { const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue); - return babel.transform(code, { filename, ...options })!; + return babel.transform(code, { filename, ...options }); } finally { isTransforming = false; } diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index a7e060cc4d..68e52c46c8 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -49,8 +49,9 @@ import { toHaveValues, toPass } from './matchers'; +import type { ExpectMatcherStateInternal } from './matchers'; import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; -import type { Expect, ExpectMatcherState } from '../../types/test'; +import type { Expect } from '../../types/test'; import { currentTestInfo } from '../common/globals'; import { filteredStackTrace, trimLongString } from '../util'; import { @@ -61,6 +62,7 @@ import { } from '../common/expectBundle'; import { zones } from 'playwright-core/lib/utils'; import { TestInfoImpl } from '../worker/testInfo'; +import type { TestStepInfoImpl } from '../worker/testInfo'; import { ExpectError, isJestError } from './matcherHint'; import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; @@ -110,13 +112,13 @@ function createMatchers(actual: unknown, info: ExpectMetaInfo, prefix: string[]) return new Proxy(expectLibrary(actual), new ExpectMetaInfoProxyHandler(info, prefix)); } -const getCustomMatchersSymbol = Symbol('get custom matchers'); +const userMatchersSymbol = Symbol('userMatchers'); function qualifiedMatcherName(qualifier: string[], matcherName: string) { return qualifier.join(':') + '$' + matcherName; } -function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record) { +function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record) { const expectInstance: Expect<{}> = new Proxy(expectLibrary, { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { const [actual, messageOrOptions] = argumentsList; @@ -130,7 +132,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re return createMatchers(actual, newInfo, prefix); }, - get: function(target: any, property: string | typeof getCustomMatchersSymbol) { + get: function(target: any, property: string | typeof userMatchersSymbol) { if (property === 'configure') return configure; @@ -139,27 +141,14 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re const qualifier = [...prefix, createGuid()]; const wrappedMatchers: any = {}; - const extendedMatchers: any = { ...customMatchers }; for (const [name, matcher] of Object.entries(matchers)) { - wrappedMatchers[name] = function(...args: any[]) { - const { isNot, promise, utils } = this; - const newThis: ExpectMatcherState = { - isNot, - promise, - utils, - timeout: currentExpectTimeout() - }; - (newThis as any).equals = throwUnsupportedExpectMatcherError; - return (matcher as any).call(newThis, ...args); - }; + wrappedMatchers[name] = wrapPlaywrightMatcherToPassNiceThis(matcher); const key = qualifiedMatcherName(qualifier, name); wrappedMatchers[key] = wrappedMatchers[name]; Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); - extendedMatchers[name] = wrappedMatchers[key]; } expectLibrary.extend(wrappedMatchers); - - return createExpect(info, qualifier, extendedMatchers); + return createExpect(info, qualifier, { ...userMatchers, ...matchers }); }; } @@ -169,8 +158,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re }; } - if (property === getCustomMatchersSymbol) - return customMatchers; + if (property === userMatchersSymbol) + return userMatchers; if (property === 'poll') { return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { @@ -197,12 +186,53 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals; } } - return createExpect(newInfo, prefix, customMatchers); + return createExpect(newInfo, prefix, userMatchers); }; return expectInstance; } +// Expect wraps matchers, so there is no way to pass this information to the raw Playwright matcher. +// Rely on sync call sequence to seed each matcher call with the context. +type MatcherCallContext = { + expectInfo: ExpectMetaInfo; + testInfo: TestInfoImpl | null; + step?: TestStepInfoImpl; +}; + +let matcherCallContext: MatcherCallContext | undefined; + +function setMatcherCallContext(context: MatcherCallContext) { + matcherCallContext = context; +} + +function takeMatcherCallContext(): MatcherCallContext { + try { + return matcherCallContext!; + } finally { + matcherCallContext = undefined; + } +} + +const defaultExpectTimeout = 5000; + +function wrapPlaywrightMatcherToPassNiceThis(matcher: any) { + return function(this: any, ...args: any[]) { + const { isNot, promise, utils } = this; + const context = takeMatcherCallContext(); + const timeout = context.expectInfo.timeout ?? context.testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout; + const newThis: ExpectMatcherStateInternal = { + isNot, + promise, + utils, + timeout, + _stepInfo: context.step, + }; + (newThis as any).equals = throwUnsupportedExpectMatcherError; + return matcher.call(newThis, ...args); + }; +} + function throwUnsupportedExpectMatcherError() { throw new Error('It looks like you are using custom expect matchers that are not compatible with Playwright. See https://aka.ms/playwright/expect-compatibility'); } @@ -299,8 +329,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } return (...args: any[]) => { const testInfo = currentTestInfo(); - // We assume that the matcher will read the current expect timeout the first thing. - setCurrentExpectConfigureTimeout(this._info.timeout); + setMatcherCallContext({ expectInfo: this._info, testInfo }); if (!testInfo) return matcher.call(target, ...args); @@ -346,6 +375,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { }; try { + setMatcherCallContext({ expectInfo: this._info, testInfo, step: step.info }); const callback = () => matcher.call(target, ...args); const result = zones.run('stepZone', step, callback); if (result instanceof Promise) @@ -362,7 +392,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) { const testInfo = currentTestInfo(); const poll = info.poll!; - const timeout = poll.timeout ?? currentExpectTimeout(); + const timeout = poll.timeout ?? info.timeout ?? testInfo?._projectInternal?.expect?.timeout ?? defaultExpectTimeout; const { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout); const result = await pollAgainstDeadline(async () => { @@ -398,22 +428,6 @@ async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, p } } -let currentExpectConfigureTimeout: number | undefined; - -function setCurrentExpectConfigureTimeout(timeout: number | undefined) { - currentExpectConfigureTimeout = timeout; -} - -function currentExpectTimeout() { - if (currentExpectConfigureTimeout !== undefined) - return currentExpectConfigureTimeout; - const testInfo = currentTestInfo(); - let defaultExpectTimeout = testInfo?._projectInternal?.expect?.timeout; - if (typeof defaultExpectTimeout === 'undefined') - defaultExpectTimeout = 5000; - return defaultExpectTimeout; -} - function computeArgsSuffix(matcherName: string, args: any[]) { let value = ''; if (matcherName === 'toHaveScreenshot') @@ -426,7 +440,7 @@ export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers export function mergeExpects(...expects: any[]) { let merged = expect; for (const e of expects) { - const internals = e[getCustomMatchersSymbol]; + const internals = e[userMatchersSymbol]; if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special continue; merged = merged.extend(internals); diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index e426cf9948..be960dd424 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -24,10 +24,13 @@ import { toMatchText } from './toMatchText'; import { isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils'; import { currentTestInfo } from '../common/globals'; import { TestInfoImpl } from '../worker/testInfo'; +import type { TestStepInfoImpl } from '../worker/testInfo'; import type { ExpectMatcherState } from '../../types/test'; import { takeFirst } from '../common/config'; import { toHaveURL as toHaveURLExternal } from './toHaveURL'; +export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl }; + export interface LocatorEx extends Locator { _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; } diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index 193b57058f..09ca66ab3c 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -29,8 +29,8 @@ import { colors } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import path from 'path'; import { mime } from 'playwright-core/lib/utilsBundle'; -import type { TestInfoImpl } from '../worker/testInfo'; -import type { ExpectMatcherState } from '../../types/test'; +import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo'; +import type { ExpectMatcherStateInternal } from './matchers'; import { matcherHint, type MatcherResult } from './matcherHint'; import type { FullProjectInternal } from '../common/config'; @@ -221,13 +221,13 @@ class SnapshotHelper { return this.createMatcherResult(message, true); } - handleMissing(actual: Buffer | string): ImageMatcherResult { + handleMissing(actual: Buffer | string, step: TestStepInfoImpl | undefined): ImageMatcherResult { const isWriteMissingMode = this.updateSnapshots !== 'none'; if (isWriteMissingMode) writeFileSync(this.expectedPath, actual); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); + step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); writeFileSync(this.actualPath, actual); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); + step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ', writing actual.' : '.'}`; if (this.updateSnapshots === 'all' || this.updateSnapshots === 'changed') { /* eslint-disable no-console */ @@ -249,28 +249,29 @@ class SnapshotHelper { diff: Buffer | string | undefined, header: string, diffError: string, - log: string[] | undefined): ImageMatcherResult { + log: string[] | undefined, + step: TestStepInfoImpl | undefined): ImageMatcherResult { const output = [`${header}${indent(diffError, ' ')}`]; if (expected !== undefined) { // Copy the expectation inside the `test-results/` folder for backwards compatibility, // so that one can upload `test-results/` directory and have all the data inside. writeFileSync(this.legacyExpectedPath, expected); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); + step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath }); output.push(`\nExpected: ${colors.yellow(this.expectedPath)}`); } if (previous !== undefined) { writeFileSync(this.previousPath, previous); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath }); + step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath }); output.push(`Previous: ${colors.yellow(this.previousPath)}`); } if (actual !== undefined) { writeFileSync(this.actualPath, actual); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); + step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath }); output.push(`Received: ${colors.yellow(this.actualPath)}`); } if (diff !== undefined) { writeFileSync(this.diffPath, diff); - this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath }); + step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath }); output.push(` Diff: ${colors.yellow(this.diffPath)}`); } @@ -288,7 +289,7 @@ class SnapshotHelper { } export function toMatchSnapshot( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, received: Buffer | string, nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {}, optOptions: ImageComparatorOptions = {} @@ -315,7 +316,7 @@ export function toMatchSnapshot( } if (!fs.existsSync(helper.expectedPath)) - return helper.handleMissing(received); + return helper.handleMissing(received, this._stepInfo); const expected = fs.readFileSync(helper.expectedPath); @@ -344,7 +345,7 @@ export function toMatchSnapshot( const receiver = isString(received) ? 'string' : 'Buffer'; const header = matcherHint(this, undefined, 'toMatchSnapshot', receiver, undefined, undefined); - return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined); + return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined, this._stepInfo); } export function toHaveScreenshotStepTitle( @@ -360,7 +361,7 @@ export function toHaveScreenshotStepTitle( } export async function toHaveScreenshot( - this: ExpectMatcherState, + this: ExpectMatcherStateInternal, pageOrLocator: Page | Locator, nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions = {}, optOptions: ToHaveScreenshotOptions = {} @@ -425,11 +426,11 @@ export async function toHaveScreenshot( // This can be due to e.g. spinning animation, so we want to show it as a diff. if (errorMessage) { const header = matcherHint(this, locator, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined); - return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log); + return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log, this._stepInfo); } // We successfully generated new screenshot. - return helper.handleMissing(actual!); + return helper.handleMissing(actual!, this._stepInfo); } // General case: @@ -460,7 +461,7 @@ export async function toHaveScreenshot( return writeFiles(); const header = matcherHint(this, undefined, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined); - return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log); + return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log, this._stepInfo); } function writeFileSync(aPath: string, content: Buffer | string) { diff --git a/packages/playwright/src/reporters/teleEmitter.ts b/packages/playwright/src/reporters/teleEmitter.ts index eadd2b05a5..76fc1490a6 100644 --- a/packages/playwright/src/reporters/teleEmitter.ts +++ b/packages/playwright/src/reporters/teleEmitter.ts @@ -256,7 +256,7 @@ export class TeleReporterEmitter implements ReporterV2 { id: (step as any)[this._idSymbol], duration: step.duration, error: step.error, - attachments: step.attachments.map(a => result.attachments.indexOf(a)), + attachments: step.attachments.length ? step.attachments.map(a => result.attachments.indexOf(a)) : undefined, annotations: step.annotations.length ? step.annotations : undefined, }; } diff --git a/packages/playwright/src/transform/babelBundle.ts b/packages/playwright/src/transform/babelBundle.ts index d2f8b5919a..349b7b9c30 100644 --- a/packages/playwright/src/transform/babelBundle.ts +++ b/packages/playwright/src/transform/babelBundle.ts @@ -20,7 +20,7 @@ export const declare: typeof import('../../bundles/babel/node_modules/@types/bab export const types: typeof import('../../bundles/babel/node_modules/@types/babel__core').types = require('./babelBundleImpl').types; export const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse; export type BabelPlugin = [string, any?]; -export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult; +export type BabelTransformFunction = (code: string, filename: string, isModule: boolean, pluginsPrefix: BabelPlugin[], pluginsSuffix: BabelPlugin[]) => BabelFileResult | null; export const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform; export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult; export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse; diff --git a/packages/playwright/src/transform/transform.ts b/packages/playwright/src/transform/transform.ts index 549d83a168..88950670be 100644 --- a/packages/playwright/src/transform/transform.ts +++ b/packages/playwright/src/transform/transform.ts @@ -232,9 +232,10 @@ export function transformHook(originalCode: string, filename: string, moduleUrl? const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle'); transformData = new Map(); - const { code, map } = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue); - if (!code) - return { code: '', serializedCache }; + const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue); + if (!babelResult?.code) + return { code: originalCode, serializedCache }; + const { code, map } = babelResult; const added = addToCache!(code, map, transformData); return { code, serializedCache: added.serializedCache }; } diff --git a/packages/playwright/src/worker/testInfo.ts b/packages/playwright/src/worker/testInfo.ts index 09ce7a0ddd..740f7579bd 100644 --- a/packages/playwright/src/worker/testInfo.ts +++ b/packages/playwright/src/worker/testInfo.ts @@ -270,7 +270,7 @@ export class TestInfoImpl implements TestInfo { ...data, steps: [], attachmentIndices, - info: new TestStepInfoImpl(), + info: new TestStepInfoImpl(this, stepId), complete: result => { if (step.endWallTime) return; @@ -417,7 +417,7 @@ export class TestInfoImpl implements TestInfo { step.complete({}); } - private _attach(attachment: TestInfo['attachments'][0], stepId: string | undefined) { + _attach(attachment: TestInfo['attachments'][0], stepId: string | undefined) { const index = this._attachmentsPush(attachment) - 1; if (stepId) { this._stepMap.get(stepId)!.attachmentIndices.push(index); @@ -510,6 +510,14 @@ export class TestInfoImpl implements TestInfo { export class TestStepInfoImpl implements TestStepInfo { annotations: Annotation[] = []; + private _testInfo: TestInfoImpl; + private _stepId: string; + + constructor(testInfo: TestInfoImpl, stepId: string) { + this._testInfo = testInfo; + this._stepId = stepId; + } + async _runStepBody(skip: boolean, body: (step: TestStepInfo) => T | Promise) { if (skip) { this.annotations.push({ type: 'skip' }); @@ -524,6 +532,14 @@ export class TestStepInfoImpl implements TestStepInfo { } } + _attachToStep(attachment: TestInfo['attachments'][0]): void { + this._testInfo._attach(attachment, this._stepId); + } + + async attach(name: string, options?: { body?: string | Buffer; contentType?: string; path?: string; }): Promise { + this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options)); + } + skip(...args: unknown[]) { // skip(); // skip(condition: boolean, description: string); diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 15bb17db06..e154d6a649 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -278,6 +278,8 @@ export class TestTracing { } function serializeAttachments(attachments: Attachment[]): trace.AfterActionTraceEvent['attachments'] { + if (attachments.length === 0) + return undefined; return attachments.filter(a => a.name !== 'trace').map(a => { return { name: a.name, diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index b4878ff659..8bd7f3dcd0 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -105,6 +105,10 @@ export class WorkerMain extends ProcessRunner { override async gracefullyClose() { try { await this._stop(); + if (!this._config) { + // We never set anything up and we can crash on attempting cleanup + return; + } // Ignore top-level errors, they are already inside TestInfo.errors. const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); const runnable = { type: 'teardown' } as const; @@ -190,15 +194,19 @@ export class WorkerMain extends ProcessRunner { if (this._config) return; - this._config = await deserializeConfig(this._params.config); - this._project = this._config.projects.find(p => p.id === this._params.projectId)!; + const config = await deserializeConfig(this._params.config); + const project = config.projects.find(p => p.id === this._params.projectId); + if (!project) + throw new Error(`Project "${this._params.projectId}" not found in the worker process. Make sure project name does not change.`); + this._config = config; + this._project = project; this._poolBuilder = PoolBuilder.createForWorker(this._project); } async runTestGroup(runPayload: RunPayload) { this._runFinished = new ManualPromise(); const entries = new Map(runPayload.entries.map(e => [e.testId, e])); - let fatalUnknownTestIds; + let fatalUnknownTestIds: string[] | undefined; try { await this._loadIfNeeded(); const fileSuite = await loadTestFile(runPayload.file, this._config.config.rootDir); diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 95eb71d899..ef839e44cb 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -349,6 +349,10 @@ interface TestProject { /** * Project name is visible in the report and during test execution. + * + * **NOTE** Playwright executes the configuration file multiple times. Do not dynamically produce non-stable values in + * your configuration. + * */ name?: string; @@ -9571,6 +9575,72 @@ export interface TestInfoError { * */ export interface TestStepInfo { + /** + * Attach a value or a file from disk to the current test step. Some reporters show test step attachments. Either + * [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path) or + * [`body`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-body) must be specified, + * but not both. Calling this method will attribute the attachment to the step, as opposed to + * [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach) which stores + * all attachments at the test level. + * + * For example, you can attach a screenshot to the test step: + * + * ```js + * import { test, expect } from '@playwright/test'; + * + * test('basic test', async ({ page }) => { + * await page.goto('https://playwright.dev'); + * await test.step('check page rendering', async step => { + * const screenshot = await page.screenshot(); + * await step.attach('screenshot', { body: screenshot, contentType: 'image/png' }); + * }); + * }); + * ``` + * + * Or you can attach files returned by your APIs: + * + * ```js + * import { test, expect } from '@playwright/test'; + * import { download } from './my-custom-helpers'; + * + * test('basic test', async ({}) => { + * await test.step('check download behavior', async step => { + * const tmpPath = await download('a'); + * await step.attach('downloaded', { path: tmpPath }); + * }); + * }); + * ``` + * + * **NOTE** + * [testStepInfo.attach(name[, options])](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach) + * automatically takes care of copying attached files to a location that is accessible to reporters. You can safely + * remove the attachment after awaiting the attach call. + * + * @param name Attachment name. The name will also be sanitized and used as the prefix of file name when saving to disk. + * @param options + */ + attach(name: string, options?: { + /** + * Attachment body. Mutually exclusive with + * [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path). + */ + body?: string|Buffer; + + /** + * Content type of this attachment to properly present in the report, for example `'application/json'` or + * `'image/png'`. If omitted, content type is inferred based on the + * [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path), or defaults to + * `text/plain` for [string] attachments and `application/octet-stream` for [Buffer] attachments. + */ + contentType?: string; + + /** + * Path on the filesystem to the attached file. Mutually exclusive with + * [`body`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-body). + */ + path?: string; + }): Promise; + /** * Unconditionally skip the currently running step. Test step is immediately aborted. This is similar to * [test.step.skip(title, body[, options])](https://playwright.dev/docs/api/class-test#test-step-skip). diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index bc8c7c56bb..b0ccdcbc2a 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -281,7 +281,9 @@ export type IndexedDBDatabase = { keyPathArray?: string[], records: { key?: any, - value: any, + keyEncoded?: any, + value?: any, + valueEncoded?: any, }[], indexes: { name: string, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index bdf55069e4..b581eacb4e 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -244,7 +244,9 @@ IndexedDBDatabase: type: object properties: key: json? - value: json + keyEncoded: json? + value: json? + valueEncoded: json? indexes: type: array items: diff --git a/tests/playwright-test/config.spec.ts b/tests/playwright-test/config.spec.ts index 5550d84253..7870d74706 100644 --- a/tests/playwright-test/config.spec.ts +++ b/tests/playwright-test/config.spec.ts @@ -392,6 +392,24 @@ test('should print nice error when some of the projects are unknown', async ({ r expect(output).toContain('Project(s) "suIte3", "SUite4" not found. Available projects: "suite1", "suite2"'); }); +test('should print nice error when project name is not stable', async ({ runInlineTest }) => { + const { output, exitCode } = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { projects: [ + { name: \`calculated \$\{Date.now()\}\` }, + ] }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({}, testInfo) => { + console.log(testInfo.project.name); + }); + ` + }); + expect(exitCode).toBe(1); + expect(output).toContain('not found in the worker process. Make sure project name does not change.'); +}); + test('should work without config file', async ({ runInlineTest }) => { const { exitCode, passed, failed, skipped } = await runInlineTest({ 'playwright.config.ts': ` diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 045ad6d419..88995c6f1c 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -761,7 +761,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest expect(result.exitCode).toBe(0); expect(result.passed).toBe(1); const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-download-page', 'trace.zip')); - const attachedScreenshots = trace.actions.flatMap(a => a.attachments); + const attachedScreenshots = trace.actions.filter(a => a.attachments).flatMap(a => a.attachments); // One screenshot for the page, no screenshot for the download page since it should have failed. expect(attachedScreenshots.length).toBe(1); }); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index b05ff8ad55..fdb026d66c 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -1019,6 +1019,27 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(attachment).toBeInViewport(); }); + test('step.attach have links', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passing test', async ({ page }, testInfo) => { + await test.step('step', async (step) => { + await step.attach('text attachment', { body: 'content', contentType: 'text/plain' }); + }) + }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + + await showReport(); + await page.getByRole('link', { name: 'passing test' }).click(); + + await page.getByLabel('step').getByTitle('reveal attachment').click(); + await page.getByText('text attachment', { exact: true }).click(); + await expect(page.locator('.attachment-body')).toHaveText('content'); + }); + test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'helper.ts': ` diff --git a/tests/playwright-test/reporter.spec.ts b/tests/playwright-test/reporter.spec.ts index 67a8250086..b2a68bfeb0 100644 --- a/tests/playwright-test/reporter.spec.ts +++ b/tests/playwright-test/reporter.spec.ts @@ -735,6 +735,43 @@ test('step attachments are referentially equal to result attachments', async ({ ]); }); +test('step.attach attachments are reported on right steps', async ({ runInlineTest }) => { + class TestReporter implements Reporter { + onStepEnd(test: TestCase, result: TestResult, step: TestStep) { + console.log('%%%', JSON.stringify({ + title: step.title, + attachments: step.attachments.map(a => ({ ...a, body: a.body.toString('utf8') })), + })); + } + } + const result = await runInlineTest({ + 'reporter.ts': `module.exports = ${TestReporter.toString()}`, + 'playwright.config.ts': `module.exports = { reporter: './reporter' };`, + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test.beforeAll(async () => { + await test.step('step in beforeAll', async (step) => { + await step.attach('attachment1', { body: 'content1' }); + }); + }); + test('test', async () => { + await test.step('step', async (step) => { + await step.attach('attachment2', { body: 'content2' }); + }); + }); + `, + }, { 'reporter': '', 'workers': 1 }); + + const steps = result.outputLines.map(line => JSON.parse(line)); + expect(steps).toEqual([ + { title: 'step in beforeAll', attachments: [{ body: 'content1', contentType: 'text/plain', name: 'attachment1' }] }, + { title: 'beforeAll hook', attachments: [] }, + { title: 'Before Hooks', attachments: [] }, + { title: 'step', attachments: [{ body: 'content2', contentType: 'text/plain', name: 'attachment2' }] }, + { title: 'After Hooks', attachments: [] }, + ]); +}); + test('attachments are reported in onStepEnd', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/14364' } }, async ({ runInlineTest }) => { class TestReporter implements Reporter { onStepEnd(test: TestCase, result: TestResult, step: TestStep) {