Merge branch 'main' into storagestate-more-tests
This commit is contained in:
commit
1e11e1987f
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "webkit",
|
"name": "webkit",
|
||||||
"revision": "2127",
|
"revision": "2130",
|
||||||
"installByDefault": true,
|
"installByDefault": true,
|
||||||
"revisionOverrides": {
|
"revisionOverrides": {
|
||||||
"debian11-x64": "2105",
|
"debian11-x64": "2105",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
50
packages/playwright-core/types/types.d.ts
vendored
50
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 }>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
70
packages/playwright/types/test.d.ts
vendored
70
packages/playwright/types/test.d.ts
vendored
|
|
@ -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).
|
||||||
|
|
|
||||||
4
packages/protocol/src/channels.d.ts
vendored
4
packages/protocol/src/channels.d.ts
vendored
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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': `
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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': `
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue