Skip to content

Commit 7f09ba7

Browse files
authored
feat: step.attach() (#34614)
1 parent 4b64c47 commit 7f09ba7

File tree

8 files changed

+233
-25
lines changed

8 files changed

+233
-25
lines changed

docs/src/test-api/class-teststepinfo.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,70 @@ test('basic test', async ({ page, browserName }, TestStepInfo) => {
1616
});
1717
```
1818

19+
## async method: TestStepInfo.attach
20+
* since: v1.51
21+
22+
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.
23+
24+
For example, you can attach a screenshot to the test step:
25+
26+
```js
27+
import { test, expect } from '@playwright/test';
28+
29+
test('basic test', async ({ page }) => {
30+
await page.goto('https://playwright.dev');
31+
await test.step('check page rendering', async step => {
32+
const screenshot = await page.screenshot();
33+
await step.attach('screenshot', { body: screenshot, contentType: 'image/png' });
34+
});
35+
});
36+
```
37+
38+
Or you can attach files returned by your APIs:
39+
40+
```js
41+
import { test, expect } from '@playwright/test';
42+
import { download } from './my-custom-helpers';
43+
44+
test('basic test', async ({}) => {
45+
await test.step('check download behavior', async step => {
46+
const tmpPath = await download('a');
47+
await step.attach('downloaded', { path: tmpPath });
48+
});
49+
});
50+
```
51+
52+
:::note
53+
[`method: TestStepInfo.attach`] automatically takes care of copying attached files to a
54+
location that is accessible to reporters. You can safely remove the attachment
55+
after awaiting the attach call.
56+
:::
57+
58+
### param: TestStepInfo.attach.name
59+
* since: v1.51
60+
- `name` <[string]>
61+
62+
Attachment name. The name will also be sanitized and used as the prefix of file name
63+
when saving to disk.
64+
65+
### option: TestStepInfo.attach.body
66+
* since: v1.51
67+
- `body` <[string]|[Buffer]>
68+
69+
Attachment body. Mutually exclusive with [`option: path`].
70+
71+
### option: TestStepInfo.attach.contentType
72+
* since: v1.51
73+
- `contentType` <[string]>
74+
75+
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.
76+
77+
### option: TestStepInfo.attach.path
78+
* since: v1.51
79+
- `path` <[string]>
80+
81+
Path on the filesystem to the attached file. Mutually exclusive with [`option: body`].
82+
1983
## method: TestStepInfo.skip#1
2084
* since: v1.51
2185

packages/playwright/src/matchers/expect.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ import {
4949
toHaveValues,
5050
toPass
5151
} from './matchers';
52+
import type { ExpectMatcherStateInternal } from './matchers';
5253
import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot';
53-
import type { Expect, ExpectMatcherState } from '../../types/test';
54+
import type { Expect } from '../../types/test';
5455
import { currentTestInfo } from '../common/globals';
5556
import { filteredStackTrace, trimLongString } from '../util';
5657
import {
@@ -61,6 +62,7 @@ import {
6162
} from '../common/expectBundle';
6263
import { zones } from 'playwright-core/lib/utils';
6364
import { TestInfoImpl } from '../worker/testInfo';
65+
import type { TestStepInfoImpl } from '../worker/testInfo';
6466
import { ExpectError, isJestError } from './matcherHint';
6567
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
6668

@@ -195,6 +197,7 @@ function createExpect(info: ExpectMetaInfo, prefix: string[], userMatchers: Reco
195197
type MatcherCallContext = {
196198
expectInfo: ExpectMetaInfo;
197199
testInfo: TestInfoImpl | null;
200+
step?: TestStepInfoImpl;
198201
};
199202

200203
let matcherCallContext: MatcherCallContext | undefined;
@@ -211,10 +214,6 @@ function takeMatcherCallContext(): MatcherCallContext {
211214
}
212215
}
213216

214-
type ExpectMatcherStateInternal = ExpectMatcherState & {
215-
_context: MatcherCallContext | undefined;
216-
};
217-
218217
const defaultExpectTimeout = 5000;
219218

220219
function wrapPlaywrightMatcherToPassNiceThis(matcher: any) {
@@ -227,7 +226,7 @@ function wrapPlaywrightMatcherToPassNiceThis(matcher: any) {
227226
promise,
228227
utils,
229228
timeout,
230-
_context: context,
229+
_stepInfo: context.step,
231230
};
232231
(newThis as any).equals = throwUnsupportedExpectMatcherError;
233232
return matcher.call(newThis, ...args);
@@ -376,6 +375,7 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
376375
};
377376

378377
try {
378+
setMatcherCallContext({ expectInfo: this._info, testInfo, step: step.info });
379379
const callback = () => matcher.call(target, ...args);
380380
const result = zones.run('stepZone', step, callback);
381381
if (result instanceof Promise)

packages/playwright/src/matchers/matchers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ import { toMatchText } from './toMatchText';
2424
import { isRegExp, isString, isTextualMimeType, pollAgainstDeadline, serializeExpectedTextValues } from 'playwright-core/lib/utils';
2525
import { currentTestInfo } from '../common/globals';
2626
import { TestInfoImpl } from '../worker/testInfo';
27+
import type { TestStepInfoImpl } from '../worker/testInfo';
2728
import type { ExpectMatcherState } from '../../types/test';
2829
import { takeFirst } from '../common/config';
2930
import { toHaveURL as toHaveURLExternal } from './toHaveURL';
3031

32+
export type ExpectMatcherStateInternal = ExpectMatcherState & { _stepInfo?: TestStepInfoImpl };
33+
3134
export interface LocatorEx extends Locator {
3235
_expect(expression: string, options: FrameExpectParams): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
3336
}

packages/playwright/src/matchers/toMatchSnapshot.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ import { colors } from 'playwright-core/lib/utilsBundle';
2929
import fs from 'fs';
3030
import path from 'path';
3131
import { mime } from 'playwright-core/lib/utilsBundle';
32-
import type { TestInfoImpl } from '../worker/testInfo';
33-
import type { ExpectMatcherState } from '../../types/test';
32+
import type { TestInfoImpl, TestStepInfoImpl } from '../worker/testInfo';
33+
import type { ExpectMatcherStateInternal } from './matchers';
3434
import { matcherHint, type MatcherResult } from './matcherHint';
3535
import type { FullProjectInternal } from '../common/config';
3636

@@ -221,13 +221,13 @@ class SnapshotHelper {
221221
return this.createMatcherResult(message, true);
222222
}
223223

224-
handleMissing(actual: Buffer | string): ImageMatcherResult {
224+
handleMissing(actual: Buffer | string, step: TestStepInfoImpl | undefined): ImageMatcherResult {
225225
const isWriteMissingMode = this.updateSnapshots !== 'none';
226226
if (isWriteMissingMode)
227227
writeFileSync(this.expectedPath, actual);
228-
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
228+
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
229229
writeFileSync(this.actualPath, actual);
230-
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
230+
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
231231
const message = `A snapshot doesn't exist at ${this.expectedPath}${isWriteMissingMode ? ', writing actual.' : '.'}`;
232232
if (this.updateSnapshots === 'all' || this.updateSnapshots === 'changed') {
233233
/* eslint-disable no-console */
@@ -249,28 +249,29 @@ class SnapshotHelper {
249249
diff: Buffer | string | undefined,
250250
header: string,
251251
diffError: string,
252-
log: string[] | undefined): ImageMatcherResult {
252+
log: string[] | undefined,
253+
step: TestStepInfoImpl | undefined): ImageMatcherResult {
253254
const output = [`${header}${indent(diffError, ' ')}`];
254255
if (expected !== undefined) {
255256
// Copy the expectation inside the `test-results/` folder for backwards compatibility,
256257
// so that one can upload `test-results/` directory and have all the data inside.
257258
writeFileSync(this.legacyExpectedPath, expected);
258-
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
259+
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-expected'), contentType: this.mimeType, path: this.expectedPath });
259260
output.push(`\nExpected: ${colors.yellow(this.expectedPath)}`);
260261
}
261262
if (previous !== undefined) {
262263
writeFileSync(this.previousPath, previous);
263-
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath });
264+
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-previous'), contentType: this.mimeType, path: this.previousPath });
264265
output.push(`Previous: ${colors.yellow(this.previousPath)}`);
265266
}
266267
if (actual !== undefined) {
267268
writeFileSync(this.actualPath, actual);
268-
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
269+
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-actual'), contentType: this.mimeType, path: this.actualPath });
269270
output.push(`Received: ${colors.yellow(this.actualPath)}`);
270271
}
271272
if (diff !== undefined) {
272273
writeFileSync(this.diffPath, diff);
273-
this.testInfo.attachments.push({ name: addSuffixToFilePath(this.attachmentBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath });
274+
step?._attachToStep({ name: addSuffixToFilePath(this.attachmentBaseName, '-diff'), contentType: this.mimeType, path: this.diffPath });
274275
output.push(` Diff: ${colors.yellow(this.diffPath)}`);
275276
}
276277

@@ -288,7 +289,7 @@ class SnapshotHelper {
288289
}
289290

290291
export function toMatchSnapshot(
291-
this: ExpectMatcherState,
292+
this: ExpectMatcherStateInternal,
292293
received: Buffer | string,
293294
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ImageComparatorOptions = {},
294295
optOptions: ImageComparatorOptions = {}
@@ -315,7 +316,7 @@ export function toMatchSnapshot(
315316
}
316317

317318
if (!fs.existsSync(helper.expectedPath))
318-
return helper.handleMissing(received);
319+
return helper.handleMissing(received, this._stepInfo);
319320

320321
const expected = fs.readFileSync(helper.expectedPath);
321322

@@ -344,7 +345,7 @@ export function toMatchSnapshot(
344345

345346
const receiver = isString(received) ? 'string' : 'Buffer';
346347
const header = matcherHint(this, undefined, 'toMatchSnapshot', receiver, undefined, undefined);
347-
return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined);
348+
return helper.handleDifferent(received, expected, undefined, result.diff, header, result.errorMessage, undefined, this._stepInfo);
348349
}
349350

350351
export function toHaveScreenshotStepTitle(
@@ -360,7 +361,7 @@ export function toHaveScreenshotStepTitle(
360361
}
361362

362363
export async function toHaveScreenshot(
363-
this: ExpectMatcherState,
364+
this: ExpectMatcherStateInternal,
364365
pageOrLocator: Page | Locator,
365366
nameOrOptions: NameOrSegments | { name?: NameOrSegments } & ToHaveScreenshotOptions = {},
366367
optOptions: ToHaveScreenshotOptions = {}
@@ -425,11 +426,11 @@ export async function toHaveScreenshot(
425426
// This can be due to e.g. spinning animation, so we want to show it as a diff.
426427
if (errorMessage) {
427428
const header = matcherHint(this, locator, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
428-
return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log);
429+
return helper.handleDifferent(actual, undefined, previous, diff, header, errorMessage, log, this._stepInfo);
429430
}
430431

431432
// We successfully generated new screenshot.
432-
return helper.handleMissing(actual!);
433+
return helper.handleMissing(actual!, this._stepInfo);
433434
}
434435

435436
// General case:
@@ -460,7 +461,7 @@ export async function toHaveScreenshot(
460461
return writeFiles();
461462

462463
const header = matcherHint(this, undefined, 'toHaveScreenshot', receiver, undefined, undefined, timedOut ? timeout : undefined);
463-
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log);
464+
return helper.handleDifferent(actual, expectScreenshotOptions.expected, previous, diff, header, errorMessage, log, this._stepInfo);
464465
}
465466

466467
function writeFileSync(aPath: string, content: Buffer | string) {

packages/playwright/src/worker/testInfo.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ export class TestInfoImpl implements TestInfo {
270270
...data,
271271
steps: [],
272272
attachmentIndices,
273-
info: new TestStepInfoImpl(),
273+
info: new TestStepInfoImpl(this, stepId),
274274
complete: result => {
275275
if (step.endWallTime)
276276
return;
@@ -417,7 +417,7 @@ export class TestInfoImpl implements TestInfo {
417417
step.complete({});
418418
}
419419

420-
private _attach(attachment: TestInfo['attachments'][0], stepId: string | undefined) {
420+
_attach(attachment: TestInfo['attachments'][0], stepId: string | undefined) {
421421
const index = this._attachmentsPush(attachment) - 1;
422422
if (stepId) {
423423
this._stepMap.get(stepId)!.attachmentIndices.push(index);
@@ -510,6 +510,14 @@ export class TestInfoImpl implements TestInfo {
510510
export class TestStepInfoImpl implements TestStepInfo {
511511
annotations: Annotation[] = [];
512512

513+
private _testInfo: TestInfoImpl;
514+
private _stepId: string;
515+
516+
constructor(testInfo: TestInfoImpl, stepId: string) {
517+
this._testInfo = testInfo;
518+
this._stepId = stepId;
519+
}
520+
513521
async _runStepBody<T>(skip: boolean, body: (step: TestStepInfo) => T | Promise<T>) {
514522
if (skip) {
515523
this.annotations.push({ type: 'skip' });
@@ -524,6 +532,14 @@ export class TestStepInfoImpl implements TestStepInfo {
524532
}
525533
}
526534

535+
_attachToStep(attachment: TestInfo['attachments'][0]): void {
536+
this._testInfo._attach(attachment, this._stepId);
537+
}
538+
539+
async attach(name: string, options?: { body?: string | Buffer; contentType?: string; path?: string; }): Promise<void> {
540+
this._attachToStep(await normalizeAndSaveAttachment(this._testInfo.outputPath(), name, options));
541+
}
542+
527543
skip(...args: unknown[]) {
528544
// skip();
529545
// skip(condition: boolean, description: string);

packages/playwright/types/test.d.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9575,6 +9575,72 @@ export interface TestInfoError {
95759575
*
95769576
*/
95779577
export interface TestStepInfo {
9578+
/**
9579+
* Attach a value or a file from disk to the current test step. Some reporters show test step attachments. Either
9580+
* [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path) or
9581+
* [`body`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-body) must be specified,
9582+
* but not both. Calling this method will attribute the attachment to the step, as opposed to
9583+
* [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach) which stores
9584+
* all attachments at the test level.
9585+
*
9586+
* For example, you can attach a screenshot to the test step:
9587+
*
9588+
* ```js
9589+
* import { test, expect } from '@playwright/test';
9590+
*
9591+
* test('basic test', async ({ page }) => {
9592+
* await page.goto('https://playwright.dev');
9593+
* await test.step('check page rendering', async step => {
9594+
* const screenshot = await page.screenshot();
9595+
* await step.attach('screenshot', { body: screenshot, contentType: 'image/png' });
9596+
* });
9597+
* });
9598+
* ```
9599+
*
9600+
* Or you can attach files returned by your APIs:
9601+
*
9602+
* ```js
9603+
* import { test, expect } from '@playwright/test';
9604+
* import { download } from './my-custom-helpers';
9605+
*
9606+
* test('basic test', async ({}) => {
9607+
* await test.step('check download behavior', async step => {
9608+
* const tmpPath = await download('a');
9609+
* await step.attach('downloaded', { path: tmpPath });
9610+
* });
9611+
* });
9612+
* ```
9613+
*
9614+
* **NOTE**
9615+
* [testStepInfo.attach(name[, options])](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach)
9616+
* automatically takes care of copying attached files to a location that is accessible to reporters. You can safely
9617+
* remove the attachment after awaiting the attach call.
9618+
*
9619+
* @param name Attachment name. The name will also be sanitized and used as the prefix of file name when saving to disk.
9620+
* @param options
9621+
*/
9622+
attach(name: string, options?: {
9623+
/**
9624+
* Attachment body. Mutually exclusive with
9625+
* [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path).
9626+
*/
9627+
body?: string|Buffer;
9628+
9629+
/**
9630+
* Content type of this attachment to properly present in the report, for example `'application/json'` or
9631+
* `'image/png'`. If omitted, content type is inferred based on the
9632+
* [`path`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-path), or defaults to
9633+
* `text/plain` for [string] attachments and `application/octet-stream` for [Buffer] attachments.
9634+
*/
9635+
contentType?: string;
9636+
9637+
/**
9638+
* Path on the filesystem to the attached file. Mutually exclusive with
9639+
* [`body`](https://playwright.dev/docs/api/class-teststepinfo#test-step-info-attach-option-body).
9640+
*/
9641+
path?: string;
9642+
}): Promise<void>;
9643+
95789644
/**
95799645
* Unconditionally skip the currently running step. Test step is immediately aborted. This is similar to
95809646
* [test.step.skip(title, body[, options])](https://playwright.dev/docs/api/class-test#test-step-skip).

0 commit comments

Comments
 (0)