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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,30 @@ await anglesReporter.saveTest();

```

### Attachments
A test can attach files to its results: console logs, network HAR files, videos, Playwright traces, page HTML snapshots and images. Angles shows each one on the test (or the step) with a viewer that suits it. The file extension decides how it is shown, so keep the real one: `.log`/`.txt`, `.json`, `.har`, `.webm`/`.mp4`, `.zip` (shown as a Playwright trace when the name contains "trace"), `.html`/`.htm`, `.png`/`.jpg`/`.jpeg`/`.gif`/`.webp`. Requires an Angles server with test attachment support.

``` javascript
anglesReporter.startTest('Guest user can pay with a saved card', 'Checkout');
anglesReporter.addAction('Pay with card');
anglesReporter.fail('Order confirmation', 'Order confirmed', 'Payment declined', '');

// Attach to the step you just reported, e.g. the page as it was when the assertion failed.
await anglesReporter.attachDataToLastStep(await page.content(), 'page.html');
await anglesReporter.attachFileToLastStep('/path/to/failure.png');

// Attach to the whole test, e.g. what Playwright recorded.
await anglesReporter.attachFile(await page.video().path(), 'checkout.webm');
await anglesReporter.attachFile('/path/to/trace.zip');
await anglesReporter.attachFile('/path/to/network.har');
await anglesReporter.attachData(consoleLines.join('\n'), 'console.log');

// Await the attach calls before saving: the file is linked when the test is saved.
await anglesReporter.saveTest();
```

Files are uploaded against the current build as soon as you attach them, so this works in batch mode too.

### Batch mode
By default every call to `saveTest()` sends the test execution to the Angles API straight away. If you'd rather send the whole test run in a single request at the end (e.g. for large runs), you can enable batch mode. The build is still created up-front and screenshots are still uploaded individually as the tests run (they need the build id), but the executions are gathered by the reporter until you call `saveAllTests()`.

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export * from './lib/models/manual/ManualFolder';
export * from './lib/models/manual/SharedStep';
export * from './lib/models/manual/CustomFieldDefinition';
export * from './lib/models/manual/Attachment';
export * from './lib/models/TestAttachment';
export * from './lib/models/manual/ManualTestRun';
export * from './lib/models/manual/ManualChangeHistoryEntry';
export * from './lib/models/requests/CreateManualTestCase';
Expand Down
73 changes: 73 additions & 0 deletions src/lib/AnglesReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ScreenshotPlatform } from './models/requests/ScreenshotPlatform';
import { Execution } from './models/Execution';
import { ImageCompareResponse } from './models/response/ImageCompareResponse';
import {Platform} from "./models/Platform";
import { TestAttachment } from './models/TestAttachment';

export class AnglesReporterClass {
private static _instance: AnglesReporterClass = new AnglesReporterClass();
Expand Down Expand Up @@ -239,6 +240,78 @@ export class AnglesReporterClass {
return this.screenshots.getBaselineCompare(screenshotId);
}

/**
* Uploads a file from disk and attaches it to the current test: a video, a Playwright
* trace, a HAR file, a console log and so on. Await it before saveTest() - the id is only
* added to the test once the upload has finished.
*
* The file extension decides how Angles shows it: .log/.txt, .json, .har, .webm/.mp4,
* .zip (a trace when the name contains "trace"), .html/.htm, or an image.
*
* @param filePath path to the file
* @param fileName optional name to show instead of the file's own (keep the extension)
*/
public async attachFile(filePath: string, fileName?: string): Promise<TestAttachment> {
const execution = this.requireExecution('attachFile');
const attachment = await this.attachments.uploadTestAttachment(this.currentBuild._id, filePath, fileName);
execution.attachments = (execution.attachments || []).concat(attachment._id);
return attachment;
}

/**
* Attaches in-memory content to the current test, e.g. a log you collected while the test
* ran. `fileName` is required: its extension decides how Angles shows the file.
*/
public async attachData(data: Buffer | string, fileName: string): Promise<TestAttachment> {
const execution = this.requireExecution('attachData');
const attachment = await this.attachments.uploadTestAttachmentData(this.currentBuild._id, data, fileName);
execution.attachments = (execution.attachments || []).concat(attachment._id);
return attachment;
}

/**
* Uploads a file from disk and attaches it to the most recent step, e.g. the page's HTML
* or a screenshot taken when an assertion failed. Await it before saveTest().
*/
public async attachFileToLastStep(filePath: string, fileName?: string): Promise<TestAttachment> {
const step = this.requireLastStep('attachFileToLastStep');
const attachment = await this.attachments.uploadTestAttachment(this.currentBuild._id, filePath, fileName);
step.attachments = (step.attachments || []).concat(attachment._id);
return attachment;
}

/**
* Attaches in-memory content to the most recent step, e.g.
* `attachDataToLastStep(await page.content(), 'page.html')` right after a fail().
*/
public async attachDataToLastStep(data: Buffer | string, fileName: string): Promise<TestAttachment> {
const step = this.requireLastStep('attachDataToLastStep');
const attachment = await this.attachments.uploadTestAttachmentData(this.currentBuild._id, data, fileName);
step.attachments = (step.attachments || []).concat(attachment._id);
return attachment;
}

// The test (and step) an attachment belongs to are taken when the call is made, not when
// the upload finishes, so a slow upload still lands on the right test.
private requireExecution(method: string): CreateExecution {
if (!this.currentBuild || !this.currentBuild._id) {
throw new Error(`${method}: start or set a build before attaching files.`);
}
if (!this.currentExecution) {
throw new Error(`${method}: call startTest() before attaching files.`);
}
return this.currentExecution;
}

private requireLastStep(method: string): Step {
this.requireExecution(method);
const steps = this.currentAction ? this.currentAction.steps : [];
if (!steps || steps.length === 0) {
throw new Error(`${method}: add a step (pass, fail, info, ...) before attaching a file to it.`);
}
return steps[steps.length - 1];
}

public addAction(name: string) {
this.currentAction = new Action();
this.currentAction.name = name;
Expand Down
2 changes: 2 additions & 0 deletions src/lib/models/Execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export class Execution {
platforms: Platform[];
tags: string[];
meta: Map<string, string>;
/** Ids of the files attached to the whole test. Step-level ones are on the steps. */
attachments: string[];
status: ExecutionStates;
executionType: ExecutionTypes;
/** Set on a manual execution: the test case it came from. */
Expand Down
5 changes: 4 additions & 1 deletion src/lib/models/Step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export class Step {
status: StepStates;
timestamp: Date;
screenshot: string;
/** Images attached while recording a manual step result. */
/**
* Images attached while recording a manual step result, or the ids of files an automated
* test attached to this step (see AnglesReporter.attachFileToLastStep).
*/
attachments: string[];
}
24 changes: 24 additions & 0 deletions src/lib/models/TestAttachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* What a test attachment holds. Decided by the server from the file extension:
* .log/.txt (log), .json, .har, .webm/.mp4 (video), .zip (trace when the name contains
* "trace", otherwise archive), .html/.htm and .png/.jpg/.jpeg/.gif/.webp (image).
*/
export type TestAttachmentKind = 'image' | 'log' | 'json' | 'har' | 'video' | 'trace' | 'archive' | 'html';

/**
* A file an automated test uploaded against its build: a log, HAR file, video, trace,
* HTML snapshot or image. List its id on the execution (or a step) to show it there.
*/
export class TestAttachment {
_id: string;
kind: TestAttachmentKind;
/** The uploaded file name, for display. */
originalName: string;
/** The type the file is served with, decided from its extension. */
mimeType: string;
size: number;
build: string;
/** The execution that listed this attachment, once it has been saved. */
execution?: string;
createdAt: Date;
}
5 changes: 5 additions & 0 deletions src/lib/models/requests/CreateExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@ export class CreateExecution {
platforms: Platform[];
tags: string[];
meta: Map<string, string>;
/**
* Ids of files attached to the whole test (see AnglesReporter.attachFile): a video, a
* trace, a HAR file or a console log.
*/
attachments?: string[];
}
46 changes: 45 additions & 1 deletion src/lib/requests/AttachmentRequests.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { AxiosInstance } from 'axios';
import { BaseRequests } from './BaseRequests';
import FormData from 'form-data';
import { Attachment } from '../models/manual/Attachment';
import { TestAttachment } from '../models/TestAttachment';
import { AttachmentsResponse } from '../models/response/ManualResponses';
import { DefaultResponse } from '../models/response/DefaultResponse';

/** Exactly one owner must be supplied. */
/**
* Exactly one owner must be supplied. For listing, `executionId` returns the files an
* automated execution claimed and `buildId` every file uploaded against a build.
*/
export interface AttachmentOwner {
testCaseId?: string;
sharedStepId?: string;
executionId?: string;
buildId?: string;
}

export class AttachmentRequests extends BaseRequests {
Expand Down Expand Up @@ -38,10 +44,48 @@ export class AttachmentRequests extends BaseRequests {
return this.post<Attachment>('attachment', formData);
}

/**
* Uploads a file from disk that an automated test produced - a console log, HAR file,
* video, Playwright trace, HTML snapshot or image - against the build the test is running
* in. Node only (reads the file with `fs`).
*
* The server decides the kind from the file extension, so keep the real one. List the
* returned id on the execution, or a step, when saving it; AnglesReporter.attachFile does
* both for you.
*/
public uploadTestAttachment(buildId: string, filePath: string, fileName?: string): Promise<TestAttachment> {
const path = require('path');
const fs = require('fs');
const fullPath = path.resolve(filePath);
return this.uploadTestAttachmentData(buildId, fs.createReadStream(fullPath), fileName || path.basename(fullPath));
}

/**
* Uploads in-memory content as a test attachment, e.g. `await page.content()` as
* "page.html" or a screenshot buffer as "failure.png". Node only. `fileName` is required:
* its extension decides the kind.
*/
public uploadTestAttachmentData(
buildId: string,
data: Buffer | string | NodeJS.ReadableStream,
fileName: string,
): Promise<TestAttachment> {
const formData = new FormData();
formData.append('attachment', data, fileName);
return this.post<TestAttachment>(`build/${buildId}/attachment`, formData, {
headers: formData.getHeaders(),
// Videos and traces can be large; let the server's limit decide.
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
}

public getAttachments(owner: AttachmentOwner): Promise<AttachmentsResponse> {
const params: any = {};
if (owner.testCaseId) params.testCaseId = owner.testCaseId;
if (owner.sharedStepId) params.sharedStepId = owner.sharedStepId;
if (owner.executionId) params.executionId = owner.executionId;
if (owner.buildId) params.buildId = owner.buildId;
return this.get<AttachmentsResponse>('attachment', { params });
}

Expand Down