Merge branch 'main' into storagestate-more-tests

This commit is contained in:
Simon Knott 2025-02-06 09:49:17 +01:00
commit 1e11e1987f
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
28 changed files with 462 additions and 88 deletions

View file

@ -80,7 +80,9 @@ Methods like [`method: APIRequestContext.get`] take the base URL into considerat
- `multiEntry` <[boolean]> - `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>> - `records` <[Array]<[Object]>>
- `key` ?<[Object]> - `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` <[Object]> - `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 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 obtained via [`method: BrowserContext.storageState`] or [`method: APIRequestContext.storageState`]. Either a path to the

View file

@ -896,7 +896,9 @@ context cookies from the response. The method will automatically follow redirect
- `multiEntry` <[boolean]> - `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>> - `records` <[Array]<[Object]>>
- `key` ?<[Object]> - `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` <[Object]> - `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. Returns storage state for this request context, contains current cookies and local storage snapshot if it was passed to the constructor.

View file

@ -1527,7 +1527,9 @@ Whether to emulate network being offline for the browser context.
- `multiEntry` <[boolean]> - `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>> - `records` <[Array]<[Object]>>
- `key` ?<[Object]> - `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` <[Object]> - `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. Returns storage state for this browser context, contains current cookies, local storage snapshot and IndexedDB snapshot.

View file

@ -280,7 +280,9 @@ Specify environment variables that will be visible to the browser. Defaults to `
- `multiEntry` <[boolean]> - `multiEntry` <[boolean]>
- `records` <[Array]<[Object]>> - `records` <[Array]<[Object]>>
- `key` ?<[Object]> - `key` ?<[Object]>
- `keyEncoded` ?<[Object]> if `key` is not JSON-serializable, this contains an encoded version that preserves types.
- `value` <[Object]> - `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). Learn more about [storage state and auth](../auth.md).

View file

@ -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. 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 ## property: TestProject.snapshotDir
* since: v1.10 * since: v1.10
- type: ?<[string]> - type: ?<[string]>

View file

@ -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 ## method: TestStepInfo.skip#1
* since: v1.51 * since: v1.51

View file

@ -27,7 +27,7 @@
}, },
{ {
"name": "webkit", "name": "webkit",
"revision": "2127", "revision": "2130",
"installByDefault": true, "installByDefault": true,
"revisionOverrides": { "revisionOverrides": {
"debian11-x64": "2105", "debian11-x64": "2105",

View file

@ -152,7 +152,9 @@ scheme.IndexedDBDatabase = tObject({
keyPathArray: tOptional(tArray(tString)), keyPathArray: tOptional(tArray(tString)),
records: tArray(tObject({ records: tArray(tObject({
key: tOptional(tAny), key: tOptional(tAny),
value: tAny, keyEncoded: tOptional(tAny),
value: tOptional(tAny),
valueEncoded: tOptional(tAny),
})), })),
indexes: tArray(tObject({ indexes: tArray(tObject({
name: tString, name: tString,

View file

@ -44,6 +44,7 @@ import { Clock } from './clock';
import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor'; import type { ClientCertificatesProxy } from './socksClientCertificatesInterceptor';
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
import * as storageScript from './storageScript'; import * as storageScript from './storageScript';
import * as utilityScriptSerializers from './isomorphic/utilityScriptSerializers';
export abstract class BrowserContext extends SdkObject { export abstract class BrowserContext extends SdkObject {
static Events = { static Events = {
@ -514,13 +515,15 @@ export abstract class BrowserContext extends SdkObject {
}; };
const originsToSave = new Set(this._origins); 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. // First try collecting storage stage from existing pages.
for (const page of this.pages()) { for (const page of this.pages()) {
const origin = page.mainFrame().origin(); const origin = page.mainFrame().origin();
if (!origin || !originsToSave.has(origin)) if (!origin || !originsToSave.has(origin))
continue; continue;
try { 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) if (storage.localStorage.length || storage.indexedDB.length)
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB });
originsToSave.delete(origin); originsToSave.delete(origin);
@ -540,7 +543,7 @@ export abstract class BrowserContext extends SdkObject {
for (const origin of originsToSave) { for (const origin of originsToSave) {
const frame = page.mainFrame(); const frame = page.mainFrame();
await frame.goto(internalMetadata, origin); await frame.goto(internalMetadata, origin);
const storage: Awaited<ReturnType<typeof storageScript.collect>> = await frame.evaluateExpression(`(${storageScript.collect})()`, { world: 'utility' }); const storage: storageScript.Storage = await frame.evaluateExpression(collectScript, { world: 'utility' });
if (storage.localStorage.length || storage.indexedDB.length) if (storage.localStorage.length || storage.indexedDB.length)
result.origins.push({ origin, localStorage: storage.localStorage, indexedDB: storage.indexedDB }); 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) { for (const originState of state.origins) {
const frame = page.mainFrame(); const frame = page.mainFrame();
await frame.goto(metadata, originState.origin); 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); await page.close(internalMetadata);
} }

View file

@ -15,10 +15,11 @@
*/ */
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import type { source } from './isomorphic/utilityScriptSerializers';
export type Storage = Omit<channels.OriginStorage, 'origin'>; export type Storage = Omit<channels.OriginStorage, 'origin'>;
export async function collect(): Promise<Storage> { export async function collect(serializers: ReturnType<typeof source>, isFirefox: boolean): Promise<Storage> {
const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => { const idbResult = await Promise.all((await indexedDB.databases()).map(async dbInfo => {
if (!dbInfo.name) if (!dbInfo.name)
throw new Error('Database name is empty'); throw new Error('Database name is empty');
@ -32,6 +33,39 @@ export async function collect(): Promise<Storage> {
}); });
} }
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 db = await idbRequestToPromise(indexedDB.open(dbInfo.name));
const transaction = db.transaction(db.objectStoreNames, 'readonly'); const transaction = db.transaction(db.objectStoreNames, 'readonly');
const stores = await Promise.all([...db.objectStoreNames].map(async storeName => { const stores = await Promise.all([...db.objectStoreNames].map(async storeName => {
@ -39,10 +73,24 @@ export async function collect(): Promise<Storage> {
const keys = await idbRequestToPromise(objectStore.getAllKeys()); const keys = await idbRequestToPromise(objectStore.getAllKeys());
const records = await Promise.all(keys.map(async key => { const records = await Promise.all(keys.map(async key => {
return { const record: channels.OriginStorage['indexedDB'][0]['stores'][0]['records'][0] = {};
key: objectStore.keyPath === null ? key : undefined,
value: await idbRequestToPromise(objectStore.get(key)) 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 => { const indexes = [...objectStore.indexNames].map(indexName => {
@ -81,7 +129,7 @@ export async function collect(): Promise<Storage> {
}; };
} }
export async function restore(originState: channels.SetOriginStorage) { export async function restore(originState: channels.SetOriginStorage, serializers: ReturnType<typeof source>) {
for (const { name, value } of (originState.localStorage || [])) for (const { name, value } of (originState.localStorage || []))
localStorage.setItem(name, value); localStorage.setItem(name, value);
@ -111,8 +159,8 @@ export async function restore(originState: channels.SetOriginStorage) {
await Promise.all(store.records.map(async record => { await Promise.all(store.records.map(async record => {
await idbRequestToPromise( await idbRequestToPromise(
objectStore.add( objectStore.add(
record.value, record.value ?? serializers.parseEvaluationResultValue(record.valueEncoded),
objectStore.keyPath === null ? record.key : undefined record.key ?? serializers.parseEvaluationResultValue(record.keyEncoded),
) )
); );
})); }));

View file

@ -9338,7 +9338,17 @@ export interface BrowserContext {
records: Array<{ records: Array<{
key?: Object; key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value: 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<{ records: Array<{
key?: Object; key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value: 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<{ records: Array<{
key?: Object; key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value: 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<{ records: Array<{
key?: Object; key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value: 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<{ records: Array<{
key?: Object; key?: Object;
/**
* if `key` is not JSON-serializable, this contains an encoded version that preserves types.
*/
keyEncoded?: Object;
value: Object; value: Object;
/**
* if `value` is not JSON-serializable, this contains an encoded version that preserves types.
*/
valueEncoded?: Object;
}>; }>;
}>; }>;
}>; }>;

View file

@ -118,15 +118,15 @@ function isTypeScript(filename: string) {
return filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.mts') || filename.endsWith('.cts'); 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) if (isTransforming)
return {}; return null;
// Prevent reentry while requiring plugins lazily. // Prevent reentry while requiring plugins lazily.
isTransforming = true; isTransforming = true;
try { try {
const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue); const options = babelTransformOptions(isTypeScript(filename), isModule, pluginsPrologue, pluginsEpilogue);
return babel.transform(code, { filename, ...options })!; return babel.transform(code, { filename, ...options });
} finally { } finally {
isTransforming = false; isTransforming = false;
} }

View file

@ -49,8 +49,9 @@ import {
toHaveValues, toHaveValues,
toPass toPass
} from './matchers'; } from './matchers';
import type { ExpectMatcherStateInternal } from './matchers';
import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; 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 { currentTestInfo } from '../common/globals';
import { filteredStackTrace, trimLongString } from '../util'; import { filteredStackTrace, trimLongString } from '../util';
import { import {
@ -61,6 +62,7 @@ import {
} from '../common/expectBundle'; } from '../common/expectBundle';
import { zones } from 'playwright-core/lib/utils'; import { zones } from 'playwright-core/lib/utils';
import { TestInfoImpl } from '../worker/testInfo'; import { TestInfoImpl } from '../worker/testInfo';
import type { TestStepInfoImpl } from '../worker/testInfo';
import { ExpectError, isJestError } from './matcherHint'; import { ExpectError, isJestError } from './matcherHint';
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; 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)); 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) { function qualifiedMatcherName(qualifier: string[], matcherName: string) {
return qualifier.join(':') + '$' + matcherName; return qualifier.join(':') + '$' + matcherName;
} }
function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Record<string, Function>) { function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record<string, Function>) {
const expectInstance: Expect<{}> = new Proxy(expectLibrary, { const expectInstance: Expect<{}> = new Proxy(expectLibrary, {
apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) { apply: function(target: any, thisArg: any, argumentsList: [unknown, ExpectMessage?]) {
const [actual, messageOrOptions] = argumentsList; const [actual, messageOrOptions] = argumentsList;
@ -130,7 +132,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
return createMatchers(actual, newInfo, prefix); return createMatchers(actual, newInfo, prefix);
}, },
get: function(target: any, property: string | typeof getCustomMatchersSymbol) { get: function(target: any, property: string | typeof userMatchersSymbol) {
if (property === 'configure') if (property === 'configure')
return configure; return configure;
@ -139,27 +141,14 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
const qualifier = [...prefix, createGuid()]; const qualifier = [...prefix, createGuid()];
const wrappedMatchers: any = {}; const wrappedMatchers: any = {};
const extendedMatchers: any = { ...customMatchers };
for (const [name, matcher] of Object.entries(matchers)) { for (const [name, matcher] of Object.entries(matchers)) {
wrappedMatchers[name] = function(...args: any[]) { wrappedMatchers[name] = wrapPlaywrightMatcherToPassNiceThis(matcher);
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);
};
const key = qualifiedMatcherName(qualifier, name); const key = qualifiedMatcherName(qualifier, name);
wrappedMatchers[key] = wrappedMatchers[name]; wrappedMatchers[key] = wrappedMatchers[name];
Object.defineProperty(wrappedMatchers[key], 'name', { value: name }); Object.defineProperty(wrappedMatchers[key], 'name', { value: name });
extendedMatchers[name] = wrappedMatchers[key];
} }
expectLibrary.extend(wrappedMatchers); expectLibrary.extend(wrappedMatchers);
return createExpect(info, qualifier, { ...userMatchers, ...matchers });
return createExpect(info, qualifier, extendedMatchers);
}; };
} }
@ -169,8 +158,8 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], customMatchers: Re
}; };
} }
if (property === getCustomMatchersSymbol) if (property === userMatchersSymbol)
return customMatchers; return userMatchers;
if (property === 'poll') { if (property === 'poll') {
return (actual: unknown, messageOrOptions?: ExpectMessage & { timeout?: number, intervals?: number[] }) => { 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; newInfo.poll!.intervals = configuration._poll.intervals ?? newInfo.poll!.intervals;
} }
} }
return createExpect(newInfo, prefix, customMatchers); return createExpect(newInfo, prefix, userMatchers);
}; };
return expectInstance; 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() { 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'); 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<any> {
} }
return (...args: any[]) => { return (...args: any[]) => {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
// We assume that the matcher will read the current expect timeout the first thing. setMatcherCallContext({ expectInfo: this._info, testInfo });
setCurrentExpectConfigureTimeout(this._info.timeout);
if (!testInfo) if (!testInfo)
return matcher.call(target, ...args); return matcher.call(target, ...args);
@ -346,6 +375,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
}; };
try { try {
setMatcherCallContext({ expectInfo: this._info, testInfo, step: step.info });
const callback = () => matcher.call(target, ...args); const callback = () => matcher.call(target, ...args);
const result = zones.run('stepZone', step, callback); const result = zones.run('stepZone', step, callback);
if (result instanceof Promise) if (result instanceof Promise)
@ -362,7 +392,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) { async function pollMatcher(qualifiedMatcherName: string, info: ExpectMetaInfo, prefix: string[], ...args: any[]) {
const testInfo = currentTestInfo(); const testInfo = currentTestInfo();
const poll = info.poll!; 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 { deadline, timeoutMessage } = testInfo ? testInfo._deadlineForMatcher(timeout) : TestInfoImpl._defaultDeadlineForMatcher(timeout);
const result = await pollAgainstDeadline<Error|undefined>(async () => { const result = await pollAgainstDeadline<Error|undefined>(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[]) { function computeArgsSuffix(matcherName: string, args: any[]) {
let value = ''; let value = '';
if (matcherName === 'toHaveScreenshot') if (matcherName === 'toHaveScreenshot')
@ -426,7 +440,7 @@ export const expect: Expect<{}> = createExpect({}, [], {}).extend(customMatchers
export function mergeExpects(...expects: any[]) { export function mergeExpects(...expects: any[]) {
let merged = expect; let merged = expect;
for (const e of expects) { 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 if (!internals) // non-playwright expects mutate the global expect, so we don't need to do anything special
continue; continue;
merged = merged.extend(internals); merged = merged.extend(internals);

View file

@ -24,10 +24,13 @@ import { toMatchText } from './toMatchText';
import { isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils'; import { isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils';
import { currentTestInfo } from '../common/globals'; import { currentTestInfo } from '../common/globals';
import { TestInfoImpl } from '../worker/testInfo'; import { TestInfoImpl } from '../worker/testInfo';
import type { TestStepInfoImpl } from '../worker/testInfo';
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherState } from '../../types/test';
import { takeFirst } from '../common/config'; import { takeFirst } from '../common/config';
import { toHaveURL as toHaveURLExternal } from './toHaveURL'; import { toHaveURL as toHaveURLExternal } from './toHaveURL';
export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl };
export interface LocatorEx extends Locator { export interface LocatorEx extends Locator {
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; _expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
} }

View file

@ -29,8 +29,8 @@ import { colors } from 'playwright-core/lib/utilsBundle';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { mime } from 'playwright-core/lib/utilsBundle'; import { mime } from 'playwright-core/lib/utilsBundle';
import type { TestInfoImpl } from '../worker/testInfo'; import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo';
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherStateInternal } from './matchers';
import { matcherHint, type MatcherResult } from './matcherHint'; import { matcherHint, type MatcherResult } from './matcherHint';
import type { FullProjectInternal } from '../common/config'; import type { FullProjectInternal } from '../common/config';
@ -221,13 +221,13 @@ class SnapshotHelper {
return this.createMatcherResult(message, true); return this.createMatcherResult(message, true);
} }
handleMissing(actual: Buffer | string): ImageMatcherResult { handleMissing(actual: Buffer | string, step: TestStepInfoImpl | undefined): ImageMatcherResult {
const isWriteMissingMode = this.updateSnapshots !== 'none'; const isWriteMissingMode = this.updateSnapshots !== 'none';
if (isWriteMissingMode) if (isWriteMissingMode)
writeFileSync(this.expectedPath, actual); 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); 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.' : '.'}`; const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ', writing actual.' : '.'}`;
if (this.updateSnapshots === 'all' || this.updateSnapshots === 'changed') { if (this.updateSnapshots === 'all' || this.updateSnapshots === 'changed') {
/* eslint-disable no-console */ /* eslint-disable no-console */
@ -249,28 +249,29 @@ class SnapshotHelper {
diff: Buffer | string | undefined, diff: Buffer | string | undefined,
header: string, header: string,
diffError: string, diffError: string,
log: string[] | undefined): ImageMatcherResult { log: string[] | undefined,
step: TestStepInfoImpl | undefined): ImageMatcherResult {
const output = [`${header}${indent(diffError, ' ')}`]; const output = [`${header}${indent(diffError, ' ')}`];
if (expected !== undefined) { if (expected !== undefined) {
// Copy the expectation inside the `test-results/` folder for backwards compatibility, // 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. // so that one can upload `test-results/` directory and have all the data inside.
writeFileSync(this.legacyExpectedPath, expected); 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)}`); output.push(`\nExpected: ${colors.yellow(this.expectedPath)}`);
} }
if (previous !== undefined) { if (previous !== undefined) {
writeFileSync(this.previousPath, previous); 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)}`); output.push(`Previous: ${colors.yellow(this.previousPath)}`);
} }
if (actual !== undefined) { if (actual !== undefined) {
writeFileSync(this.actualPath, actual); 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)}`); output.push(`Received: ${colors.yellow(this.actualPath)}`);
} }
if (diff !== undefined) { if (diff !== undefined) {
writeFileSync(this.diffPath, diff); 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)}`); output.push(` Diff: ${colors.yellow(this.diffPath)}`);
} }
@ -288,7 +289,7 @@ class SnapshotHelper {
} }
export function toMatchSnapshot( export function toMatchSnapshot(
this: ExpectMatcherState, this: ExpectMatcherStateInternal,
received: Buffer | string, received: Buffer | string,
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {}, nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {},
optOptions: ImageComparatorOptions = {} optOptions: ImageComparatorOptions = {}
@ -315,7 +316,7 @@ export function toMatchSnapshot(
} }
if (!fs.existsSync(helper.expectedPath)) if (!fs.existsSync(helper.expectedPath))
return helper.handleMissing(received); return helper.handleMissing(received, this._stepInfo);
const expected = fs.readFileSync(helper.expectedPath); const expected = fs.readFileSync(helper.expectedPath);
@ -344,7 +345,7 @@ export function toMatchSnapshot(
const receiver = isString(received) ? 'string' : 'Buffer'; const receiver = isString(received) ? 'string' : 'Buffer';
const header = matcherHint(this, undefined, 'toMatchSnapshot', receiver, undefined, undefined); 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( export function toHaveScreenshotStepTitle(
@ -360,7 +361,7 @@ export function toHaveScreenshotStepTitle(
} }
export async function toHaveScreenshot( export async function toHaveScreenshot(
this: ExpectMatcherState, this: ExpectMatcherStateInternal,
pageOrLocator: Page | Locator, pageOrLocator: Page | Locator,
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions = {}, nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions = {},
optOptions: 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. // This can be due to e.g. spinning animation, so we want to show it as a diff.
if (errorMessage) { if (errorMessage) {
const header = matcherHint(this, locator, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined); 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. // We successfully generated new screenshot.
return helper.handleMissing(actual!); return helper.handleMissing(actual!, this._stepInfo);
} }
// General case: // General case:
@ -460,7 +461,7 @@ export async function toHaveScreenshot(
return writeFiles(); return writeFiles();
const header = matcherHint(this, undefined, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined); 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) { function writeFileSync(aPath: string, content: Buffer | string) {

View file

@ -256,7 +256,7 @@ export class TeleReporterEmitter implements ReporterV2 {
id: (step as any)[this._idSymbol], id: (step as any)[this._idSymbol],
duration: step.duration, duration: step.duration,
error: step.error, 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, annotations: step.annotations.length ? step.annotations : undefined,
}; };
} }

View file

@ -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 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 const traverse: typeof import('../../bundles/babel/node_modules/@types/babel__traverse').default = require('./babelBundleImpl').traverse;
export type BabelPlugin = [string, any?]; 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 const babelTransform: BabelTransformFunction = require('./babelBundleImpl').babelTransform;
export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult; export type BabelParseFunction = (code: string, filename: string, isModule: boolean) => ParseResult;
export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse; export const babelParse: BabelParseFunction = require('./babelBundleImpl').babelParse;

View file

@ -232,9 +232,10 @@ export function transformHook(originalCode: string, filename: string, moduleUrl?
const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle'); const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle');
transformData = new Map<string, any>(); transformData = new Map<string, any>();
const { code, map } = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue); const babelResult = babelTransform(originalCode, filename, !!moduleUrl, pluginsPrologue, pluginsEpilogue);
if (!code) if (!babelResult?.code)
return { code: '', serializedCache }; return { code: originalCode, serializedCache };
const { code, map } = babelResult;
const added = addToCache!(code, map, transformData); const added = addToCache!(code, map, transformData);
return { code, serializedCache: added.serializedCache }; return { code, serializedCache: added.serializedCache };
} }

View file

@ -270,7 +270,7 @@ export class TestInfoImpl implements TestInfo {
...data, ...data,
steps: [], steps: [],
attachmentIndices, attachmentIndices,
info: new TestStepInfoImpl(), info: new TestStepInfoImpl(this, stepId),
complete: result => { complete: result => {
if (step.endWallTime) if (step.endWallTime)
return; return;
@ -417,7 +417,7 @@ export class TestInfoImpl implements TestInfo {
step.complete({}); 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; const index = this._attachmentsPush(attachment) - 1;
if (stepId) { if (stepId) {
this._stepMap.get(stepId)!.attachmentIndices.push(index); this._stepMap.get(stepId)!.attachmentIndices.push(index);
@ -510,6 +510,14 @@ export class TestInfoImpl implements TestInfo {
export class TestStepInfoImpl implements TestStepInfo { export class TestStepInfoImpl implements TestStepInfo {
annotations: Annotation[] = []; annotations: Annotation[] = [];
private _testInfo: TestInfoImpl;
private _stepId: string;
constructor(testInfo: TestInfoImpl, stepId: string) {
this._testInfo = testInfo;
this._stepId = stepId;
}
async _runStepBody<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>) { async _runStepBody<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>) {
if (skip) { if (skip) {
this.annotations.push({ type: '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<void> {
this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options));
}
skip(...args: unknown[]) { skip(...args: unknown[]) {
// skip(); // skip();
// skip(condition: boolean, description: string); // skip(condition: boolean, description: string);

View file

@ -278,6 +278,8 @@ export class TestTracing {
} }
function serializeAttachments(attachments: Attachment[]): trace.AfterActionTraceEvent['attachments'] { function serializeAttachments(attachments: Attachment[]): trace.AfterActionTraceEvent['attachments'] {
if (attachments.length === 0)
return undefined;
return attachments.filter(a => a.name !== 'trace').map(a => { return attachments.filter(a => a.name !== 'trace').map(a => {
return { return {
name: a.name, name: a.name,

View file

@ -105,6 +105,10 @@ export class WorkerMain extends ProcessRunner {
override async gracefullyClose() { override async gracefullyClose() {
try { try {
await this._stop(); 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. // Ignore top-level errors, they are already inside TestInfo.errors.
const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {}); const fakeTestInfo = new TestInfoImpl(this._config, this._project, this._params, undefined, 0, () => {}, () => {}, () => {});
const runnable = { type: 'teardown' } as const; const runnable = { type: 'teardown' } as const;
@ -190,15 +194,19 @@ export class WorkerMain extends ProcessRunner {
if (this._config) if (this._config)
return; return;
this._config = await deserializeConfig(this._params.config); const config = await deserializeConfig(this._params.config);
this._project = this._config.projects.find(p => p.id === this._params.projectId)!; 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); this._poolBuilder = PoolBuilder.createForWorker(this._project);
} }
async runTestGroup(runPayload: RunPayload) { async runTestGroup(runPayload: RunPayload) {
this._runFinished = new ManualPromise<void>(); this._runFinished = new ManualPromise<void>();
const entries = new Map(runPayload.entries.map(e => [e.testId, e])); const entries = new Map(runPayload.entries.map(e => [e.testId, e]));
let fatalUnknownTestIds; let fatalUnknownTestIds: string[] | undefined;
try { try {
await this._loadIfNeeded(); await this._loadIfNeeded();
const fileSuite = await loadTestFile(runPayload.file, this._config.config.rootDir); const fileSuite = await loadTestFile(runPayload.file, this._config.config.rootDir);

View file

@ -349,6 +349,10 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
/** /**
* Project name is visible in the report and during test execution. * 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; name?: string;
@ -9571,6 +9575,72 @@ export interface TestInfoError {
* *
*/ */
export interface TestStepInfo { 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<void>;
/** /**
* Unconditionally skip the currently running step. Test step is immediately aborted. This is similar to * 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). * [test.step.skip(title, body[, options])](https://playwright.dev/docs/api/class-test#test-step-skip).

View file

@ -281,7 +281,9 @@ export type IndexedDBDatabase = {
keyPathArray?: string[], keyPathArray?: string[],
records: { records: {
key?: any, key?: any,
value: any, keyEncoded?: any,
value?: any,
valueEncoded?: any,
}[], }[],
indexes: { indexes: {
name: string, name: string,

View file

@ -244,7 +244,9 @@ IndexedDBDatabase:
type: object type: object
properties: properties:
key: json? key: json?
value: json keyEncoded: json?
value: json?
valueEncoded: json?
indexes: indexes:
type: array type: array
items: items:

View file

@ -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"'); 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 }) => { test('should work without config file', async ({ runInlineTest }) => {
const { exitCode, passed, failed, skipped } = await runInlineTest({ const { exitCode, passed, failed, skipped } = await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `

View file

@ -761,7 +761,7 @@ test('should not throw when screenshot on failure fails', async ({ runInlineTest
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1); expect(result.passed).toBe(1);
const trace = await parseTrace(testInfo.outputPath('test-results', 'a-has-download-page', 'trace.zip')); 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. // One screenshot for the page, no screenshot for the download page since it should have failed.
expect(attachedScreenshots.length).toBe(1); expect(attachedScreenshots.length).toBe(1);
}); });

View file

@ -1019,6 +1019,27 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await expect(attachment).toBeInViewport(); 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 }) => { test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'helper.ts': ` 'helper.ts': `

View file

@ -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 }) => { test('attachments are reported in onStepEnd', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/14364' } }, async ({ runInlineTest }) => {
class TestReporter implements Reporter { class TestReporter implements Reporter {
onStepEnd(test: TestCase, result: TestResult, step: TestStep) { onStepEnd(test: TestCase, result: TestResult, step: TestStep) {