From c1953fc473e3b0720238c1acbc5d2949aec78867 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:18:16 +0000 Subject: [PATCH] feat(attachments): attach logs, HAR files, videos, traces and HTML to tests Adds reporter methods that upload a file against the current build and add it to the current test or to its last step: - attachFile(filePath, fileName?) and attachData(data, fileName) for the whole test (video, trace, HAR, console log) - attachFileToLastStep / attachDataToLastStep for the most recent step (e.g. page.content() as page.html right after a fail()) The id is added to the test or step once the upload finishes, so callers await the attach before saveTest(). It works in batch mode too, because the upload only needs the build id. Also adds AttachmentRequests.uploadTestAttachment / uploadTestAttachmentData (POST /build/:buildId/attachment), executionId and buildId filters on getAttachments, a TestAttachment model, and `attachments` on CreateExecution and Execution. Needs an Angles server with test attachment support (AnglesHQ/angles, same branch name). minor_bump Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgQXSUjuLVXmLWxobnMfSf --- README.md | 24 +++++++ src/index.ts | 1 + src/lib/AnglesReporter.ts | 73 ++++++++++++++++++++++ src/lib/models/Execution.ts | 2 + src/lib/models/Step.ts | 5 +- src/lib/models/TestAttachment.ts | 24 +++++++ src/lib/models/requests/CreateExecution.ts | 5 ++ src/lib/requests/AttachmentRequests.ts | 46 +++++++++++++- 8 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/lib/models/TestAttachment.ts 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 }); }