diff --git a/README.md b/README.md index e852744..9846140 100644 --- a/README.md +++ b/README.md @@ -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()`. diff --git a/src/index.ts b/src/index.ts index 86295d1..e7edc12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/lib/AnglesReporter.ts b/src/lib/AnglesReporter.ts index 51465bc..e85e460 100644 --- a/src/lib/AnglesReporter.ts +++ b/src/lib/AnglesReporter.ts @@ -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(); @@ -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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/lib/models/Execution.ts b/src/lib/models/Execution.ts index 66b87f9..a1810ad 100644 --- a/src/lib/models/Execution.ts +++ b/src/lib/models/Execution.ts @@ -16,6 +16,8 @@ export class Execution { platforms: Platform[]; tags: string[]; meta: Map; + /** 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. */ diff --git a/src/lib/models/Step.ts b/src/lib/models/Step.ts index 8b348d4..fe445b9 100644 --- a/src/lib/models/Step.ts +++ b/src/lib/models/Step.ts @@ -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[]; } diff --git a/src/lib/models/TestAttachment.ts b/src/lib/models/TestAttachment.ts new file mode 100644 index 0000000..1605558 --- /dev/null +++ b/src/lib/models/TestAttachment.ts @@ -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; +} diff --git a/src/lib/models/requests/CreateExecution.ts b/src/lib/models/requests/CreateExecution.ts index 9b0b3d2..407d421 100644 --- a/src/lib/models/requests/CreateExecution.ts +++ b/src/lib/models/requests/CreateExecution.ts @@ -10,4 +10,9 @@ export class CreateExecution { platforms: Platform[]; tags: string[]; meta: Map; + /** + * Ids of files attached to the whole test (see AnglesReporter.attachFile): a video, a + * trace, a HAR file or a console log. + */ + attachments?: string[]; } diff --git a/src/lib/requests/AttachmentRequests.ts b/src/lib/requests/AttachmentRequests.ts index b4db6ec..e969201 100644 --- a/src/lib/requests/AttachmentRequests.ts +++ b/src/lib/requests/AttachmentRequests.ts @@ -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 { @@ -38,10 +44,48 @@ export class AttachmentRequests extends BaseRequests { return this.post('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 { + 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 { + const formData = new FormData(); + formData.append('attachment', data, fileName); + return this.post(`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 { 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('attachment', { params }); }