Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions docs/src/test-api/class-testinfo.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ Learn more about [test annotations](./test-annotations.md).
- `path` <[void]|[string]> Optional path on the filesystem to the attached file.
- `body` <[void]|[Buffer]> Optional attachment body used instead of a file.

The list of files or buffers attached to the current test. Some reporters show test attachments. For example, you can attach a screenshot to the test.
The list of files or buffers attached to the current test. Some reporters show test attachments.

To safely add a file from disk as an attachment, please use [`method: TestInfo.attach#1`] instead of directly pushing onto this array. For inline attachments, use [`method: TestInfo.attach#1`].

## method: TestInfo.attach#1
Attach a file from disk to the current test. Some reporters show test attachments. The [`option: name`] and [`option: contentType`] will be inferred by default from the [`param: path`], but you can optionally override either of these.

For example, you can attach a screenshot to the test:

```js js-flavor=js
const { test, expect } = require('@playwright/test');
Expand All @@ -49,7 +56,11 @@ test('basic test', async ({ page }, testInfo) => {
// Capture a screenshot and attach it.
const path = testInfo.outputPath('screenshot.png');
await page.screenshot({ path });
testInfo.attachments.push({ name: 'screenshot', path, contentType: 'image/png' });
await testInfo.attach(path);
// Optionally override the name.
await testInfo.attach(path, { name: 'example.png' });
// Optionally override the contentType.
await testInfo.attach(path, { name: 'example.custom-file', contentType: 'x-custom-content-type' });
});
```

Expand All @@ -62,10 +73,65 @@ test('basic test', async ({ page }, testInfo) => {
// Capture a screenshot and attach it.
const path = testInfo.outputPath('screenshot.png');
await page.screenshot({ path });
testInfo.attachments.push({ name: 'screenshot', path, contentType: 'image/png' });
await testInfo.attach(path);
// Optionally override the name.
await testInfo.attach(path, { name: 'example.png' });
// Optionally override the contentType.
await testInfo.attach(path, { name: 'example.custom-file', contentType: 'x-custom-content-type' });
});
```

Or you can attach files returned by your APIs:

```js js-flavor=js
const { test, expect } = require('@playwright/test');

test('basic test', async ({}, testInfo) => {
const { download } = require('./my-custom-helpers');
const tmpPath = await download('a');
await testInfo.attach(tmpPath, { name: 'example.json' });
});
```

```js js-flavor=ts
import { test, expect } from '@playwright/test';

test('basic test', async ({}, testInfo) => {
const { download } = require('./my-custom-helpers');
const tmpPath = await download('a');
await testInfo.attach(tmpPath, { name: 'example.json' });
});
```

:::note
[`method: TestInfo.attach#1`] automatically takes care of copying attachments to a
location that is accessible to reporters, even if you were to delete the attachment
after awaiting the attach call.
:::

### param: TestInfo.attach#1.path
- `path` <[string]> Path on the filesystem to the attached file.

### option: TestInfo.attach#1.name
- `name` <[void]|[string]> Optional attachment name. If omitted, this will be inferred from [`param: path`].

### option: TestInfo.attach#1.contentType
- `contentType` <[void]|[string]> Optional content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`. If omitted, this falls back to an inferred type based on the [`param: name`] (if set) or [`param: path`]'s extension; it will be set to `application/octet-stream` if the type cannot be inferred from the file extension.


## method: TestInfo.attach#2

Attach data to the current test, either a `string` or a `Buffer`. Some reporters show test attachments.

### param: TestInfo.attach#2.body
- `body` <[string]|[Buffer]> Attachment body.

### param: TestInfo.attach#2.name
- `name` <[string]> Attachment name.

### option: TestInfo.attach#2.contentType
- `contentType` <[void]|[string]> Optional content type of this attachment to properly present in the report, for example `'application/json'` or `'application/xml'`. If omitted, this falls back to an inferred type based on the [`param: name`]'s extension; if the type cannot be inferred from the name's extension, it will be set to `text/plain` (if [`param: body`] is a `string`) or `application/octet-stream` (if [`param: body`] is a `Buffer`).

## property: TestInfo.column
- type: <[int]>

Expand Down
36 changes: 36 additions & 0 deletions packages/playwright-test/src/workerRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import fs from 'fs';
import path from 'path';
import rimraf from 'rimraf';
import * as mime from 'mime';
import util from 'util';
import colors from 'colors/safe';
import { EventEmitter } from 'events';
Expand All @@ -29,6 +30,7 @@ import { Annotations, TestError, TestInfo, TestInfoImpl, TestStepInternal, Worke
import { ProjectImpl } from './project';
import { FixturePool, FixtureRunner } from './fixtures';
import { DeadlineRunner, raceAgainstDeadline } from 'playwright-core/lib/utils/async';
import { calculateFileSha1 } from 'playwright-core/lib/utils/utils';

const removeFolderAsync = util.promisify(rimraf);

Expand Down Expand Up @@ -264,6 +266,40 @@ export class WorkerRunner extends EventEmitter {
expectedStatus: test.expectedStatus,
annotations: [],
attachments: [],
attach: async (...args) => {
const [ pathOrBody, nameOrFileOptions, inlineOptions ] = args as [string | Buffer, string | { contentType?: string, name?: string} | undefined, { contentType?: string } | undefined];
let attachment: { name: string, contentType: string, body?: Buffer, path?: string } | undefined;
if (typeof nameOrFileOptions === 'string') { // inline attachment
const body = pathOrBody;
const name = nameOrFileOptions;

attachment = {
name,
contentType: inlineOptions?.contentType ?? (mime.getType(name) || (typeof body === 'string' ? 'text/plain' : 'application/octet-stream')),
body: typeof body === 'string' ? Buffer.from(body) : body,
};
} else { // path based attachment
const options = nameOrFileOptions;
const thePath = pathOrBody as string;
const name = options?.name ?? path.basename(thePath);
attachment = {
name,
path: thePath,
contentType: options?.contentType ?? (mime.getType(name) || 'application/octet-stream')
};
}

const tmpAttachment = { ...attachment };
if (attachment.path) {
const hash = await calculateFileSha1(attachment.path);
const dest = testInfo.outputPath('attachments', hash + path.extname(attachment.path));
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.copyFile(attachment.path, dest);
tmpAttachment.path = dest;
}

testInfo.attachments.push(tmpAttachment);
},
duration: 0,
status: 'passed',
stdout: [],
Expand Down
47 changes: 43 additions & 4 deletions packages/playwright-test/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,8 +1298,19 @@ export interface TestInfo {
*/
annotations: { type: string, description?: string }[];
/**
* The list of files or buffers attached to the current test. Some reporters show test attachments. For example, you can
* attach a screenshot to the test.
* The list of files or buffers attached to the current test. Some reporters show test attachments.
*
* To safely add a file from disk as an attachment, please use
* [testInfo.attach(path[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach-1) instead of
* directly pushing onto this array. For inline attachments, use
* [testInfo.attach(path[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach-1).
*/
attachments: { name: string, path?: string, body?: Buffer, contentType: string }[];
/**
* Attach a file from disk to the current test. Some reporters show test attachments. The `name` and `contentType` will be
* inferred by default from the `path`, but you can optionally override either of these.
*
* For example, you can attach a screenshot to the test:
*
* ```ts
* import { test, expect } from '@playwright/test';
Expand All @@ -1310,12 +1321,40 @@ export interface TestInfo {
* // Capture a screenshot and attach it.
* const path = testInfo.outputPath('screenshot.png');
* await page.screenshot({ path });
* testInfo.attachments.push({ name: 'screenshot', path, contentType: 'image/png' });
* await testInfo.attach(path);
* // Optionally override the name.
* await testInfo.attach(path, { name: 'example.png' });
* // Optionally override the contentType.
* await testInfo.attach(path, { name: 'example.custom-file', contentType: 'x-custom-content-type' });
* });
* ```
*
* Or you can attach files returned by your APIs:
*
* ```ts
* import { test, expect } from '@playwright/test';
*
* test('basic test', async ({}, testInfo) => {
* const { download } = require('./my-custom-helpers');
* const tmpPath = await download('a');
* await testInfo.attach(tmpPath, { name: 'example.json' });
* });
* ```
*
* > NOTE: [testInfo.attach(path[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach-1)
* automatically takes care of copying attachments to a location that is accessible to reporters, even if you were to
* delete the attachment after awaiting the attach call.
* @param path
* @param options
*/
attachments: { name: string, path?: string, body?: Buffer, contentType: string }[];
attach(path: string, options?: { contentType?: string, name?: string}): Promise<void>;
/**
* Attach data to the current test, either a `string` or a `Buffer`. Some reporters show test attachments.
* @param body
* @param name
* @param options
*/
attach(body: string | Buffer, name: string, options?: { contentType?: string }): Promise<void>;
/**
* Specifies a unique repeat index when running in "repeat each" mode. This mode is enabled by passing `--repeat-each` to
* the [command line](https://playwright.dev/docs/test-cli).
Expand Down
32 changes: 32 additions & 0 deletions tests/playwright-test/reporter-attachment.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,35 @@ test('render trace attachment', async ({ runInlineTest }) => {
expect(text).toContain(' ------------------------------------------------------------------------------------------------');
expect(result.exitCode).toBe(1);
});


test(`testInfo.attach throws an error when attaching a non-existent attachment`, async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.js': `
const { test } = pwt;
test('all options specified', async ({}, testInfo) => {
await testInfo.attach('non-existent-path-all-options', { contentType: 'text/plain', name: 'foo.txt'});
});

test('no options specified', async ({}, testInfo) => {
await testInfo.attach('non-existent-path-no-options');
});

test('partial options - contentType', async ({}, testInfo) => {
await testInfo.attach('non-existent-path-partial-options-content-type', { contentType: 'text/plain'});
});

test('partial options - name', async ({}, testInfo) => {
await testInfo.attach('non-existent-path-partial-options-name', { name: 'foo.txt'});
});
`,
}, { reporter: 'line', workers: 1 });
const text = stripAscii(result.output).replace(/\\/g, '/');
expect(text).toMatch(/Error: ENOENT: no such file or directory, open '.*non-existent-path-all-options.*'/);
expect(text).toMatch(/Error: ENOENT: no such file or directory, open '.*non-existent-path-no-options.*'/);
expect(text).toMatch(/Error: ENOENT: no such file or directory, open '.*non-existent-path-partial-options-content-type.*'/);
expect(text).toMatch(/Error: ENOENT: no such file or directory, open '.*non-existent-path-partial-options-name.*'/);
expect(result.passed).toBe(0);
expect(result.failed).toBe(4);
expect(result.exitCode).toBe(1);
});
Loading