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]>
- `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

View file

@ -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.

View file

@ -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.

View file

@ -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).

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.
:::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]>

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
* since: v1.51

View file

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

View file

@ -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,

View file

@ -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<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)
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);
}

View file

@ -15,10 +15,11 @@
*/
import type * as channels from '@protocol/channels';
import type { source } from './isomorphic/utilityScriptSerializers';
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 => {
if (!dbInfo.name)
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 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<Storage> {
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<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 || []))
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),
)
);
}));

View file

@ -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;
}>;
}>;
}>;

View file

@ -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;
}

View file

@ -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<string, Function>) {
function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Record<string, Function>) {
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<any> {
}
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<any> {
};
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<any> {
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<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[]) {
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);

View file

@ -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 }>;
}

View file

@ -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) {

View file

@ -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,
};
}

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 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;

View file

@ -232,9 +232,10 @@ export function transformHook(originalCode: string, filename: string, moduleUrl?
const { babelTransform }: { babelTransform: BabelTransformFunction } = require('./babelBundle');
transformData = new Map<string, any>();
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 };
}

View file

@ -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<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>) {
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<void> {
this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options));
}
skip(...args: unknown[]) {
// skip();
// skip(condition: boolean, description: string);

View file

@ -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,

View file

@ -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<void>();
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);

View file

@ -349,6 +349,10 @@ interface TestProject<TestArgs = {}, WorkerArgs = {}> {
/**
* 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<void>;
/**
* 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).

View file

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

View file

@ -244,7 +244,9 @@ IndexedDBDatabase:
type: object
properties:
key: json?
value: json
keyEncoded: json?
value: json?
valueEncoded: json?
indexes:
type: array
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"');
});
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': `

View file

@ -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);
});

View file

@ -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': `

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 }) => {
class TestReporter implements Reporter {
onStepEnd(test: TestCase, result: TestResult, step: TestStep) {