diff --git a/src/allureReader.js b/src/allureReader.js index a15e2dbf..66f5949d 100644 --- a/src/allureReader.js +++ b/src/allureReader.js @@ -183,7 +183,7 @@ class AllureReader { suite_title: this.extractSuiteTitle(result), file: this.extractFile(result), run_time: this.calculateRunTime(result), - steps: this.convertSteps(result.steps || []), + steps: this.convertSteps(result.steps || [], resultsDir), message: result.statusDetails?.message || '', stack: result.statusDetails?.trace || '', meta: this.extractMeta(result), @@ -209,21 +209,9 @@ class AllureReader { test.example = this.convertParameters(result.parameters); } - if (result.attachments && result.attachments.length > 0) { - const attachments = result.attachments - .map(att => { - const fullPath = path.join(resultsDir, att.source); - if (fs.existsSync(fullPath)) { - return fullPath; - } - debug('Attachment file not found:', fullPath); - return null; - }) - .filter(Boolean); - - if (attachments.length > 0) { - test.files = attachments; - } + const attachments = this.resolveAttachments(result.attachments, resultsDir); + if (attachments.length > 0) { + test.files = attachments; } return test; @@ -531,7 +519,7 @@ class AllureReader { return this.extractTmsIdsFromSource(contents, test)[0] || null; } - convertSteps(steps, depth = 0) { + convertSteps(steps, resultsDir = '', depth = 0) { if (depth >= 10) return null; return steps @@ -541,9 +529,15 @@ class AllureReader { title: step.name || step.title || 'Unknown step', status: this.mapStepStatus(step.status), duration: this.calculateRunTime(step), - steps: this.convertSteps(step.steps || [], depth + 1), + steps: this.convertSteps(step.steps || [], resultsDir, depth + 1), }; + // step attachments stay on the step; uploadArtifacts() swaps the paths for links + const attachments = this.resolveAttachments(step.attachments, resultsDir); + if (attachments.length > 0) { + convertedStep.artifacts = attachments; + } + // Attach the failure description (error message + trace with the failing // code line) straight onto the failed step. Testomat.io renders a step's // `error` inline in the step tree, so the failure shows up on the exact @@ -876,16 +870,68 @@ class AllureReader { return paths; } + /** + * @param {Array<{source?: string}>|undefined} attachments + * @param {string} resultsDir + * @returns {string[]} paths of attachments that exist on disk + */ + resolveAttachments(attachments, resultsDir) { + if (!attachments || !attachments.length) return []; + + return attachments + .map(att => { + if (!att?.source) return null; + const fullPath = path.join(resultsDir || '', att.source); + if (fs.existsSync(fullPath)) return fullPath; + debug('Attachment file not found:', fullPath); + return null; + }) + .filter(Boolean); + } + + /** + * Replaces step artifact paths with S3 links, dropping failed uploads (a local path + * would be a dead link in the UI). + * + * @param {Array|undefined} steps + * @param {string} runId + * @param {string} rid + * @returns {Promise} number of uploaded step artifacts + */ + async uploadStepArtifacts(steps, runId, rid) { + if (!steps || !steps.length) return 0; + + let uploaded = 0; + for (const step of steps) { + if (step.artifacts?.length) { + const links = await Promise.all( + step.artifacts.map(f => this.uploader.uploadFileByPath(f, [runId, rid, 'steps', path.basename(f)])), + ); + step.artifacts = links.filter(link => !!link); + uploaded += step.artifacts.length; + if (!step.artifacts.length) delete step.artifacts; + } + uploaded += await this.uploadStepArtifacts(step.steps, runId, rid); + } + return uploaded; + } + async uploadArtifacts() { - for (const test of this._tests.filter(t => t.files && t.files.length > 0)) { + for (const test of this._tests) { const runId = this.runId || this.store.runId || Date.now().toString(); - const artifacts = await Promise.all( - test.files.map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])), + + // uploadFileByPath resolves to a link string, or undefined if skipped or failed + const links = await Promise.all( + (test.files || []).map(f => this.uploader.uploadFileByPath(f, [runId, test.rid, path.basename(f)])), ); - test.artifacts = artifacts.filter(a => a && a.link).map(a => a.link); + test.artifacts = links.filter(link => !!link); delete test.files; - if (test.artifacts.length > 0) { - console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`); + + const stepArtifacts = await this.uploadStepArtifacts(test.steps, runId, test.rid); + + const total = test.artifacts.length + stepArtifacts; + if (total > 0) { + console.log(APP_PREFIX, `🗄️ Uploaded ${pc.bold(`${total} artifacts`)} for test ${test.title}`); } } } diff --git a/src/xmlReader.js b/src/xmlReader.js index d00419cc..e3cadf6d 100644 --- a/src/xmlReader.js +++ b/src/xmlReader.js @@ -533,8 +533,10 @@ class XmlReader { if (!files.length) continue; const runId = this.runId || this.store.runId || Date.now().toString(); - test.artifacts = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId, path.basename(f)]))); - log.info(`🗄️ Uploaded ${pc.bold(`${files.length} artifacts`)} for test ${test.title}`); + // undefined for skipped/failed uploads; keeping those serializes as `null` links + const links = await Promise.all(files.map(f => this.uploader.uploadFileByPath(f, [runId, path.basename(f)]))); + test.artifacts = links.filter(link => !!link); + log.info(`🗄️ Uploaded ${pc.bold(`${test.artifacts.length} artifacts`)} for test ${test.title}`); } } diff --git a/tests/unit/allure_artifacts_test.js b/tests/unit/allure_artifacts_test.js new file mode 100644 index 00000000..6d1738bb --- /dev/null +++ b/tests/unit/allure_artifacts_test.js @@ -0,0 +1,151 @@ +import { expect } from 'chai'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import AllureReader from '../../src/allureReader.js'; + +// Assertions round-trip through JSON on purpose: reading `.link` off the link strings +// resolved to `String.prototype.link`, which stayed truthy and countable until the +// payload was serialized and every artifact turned into `null`. +describe('AllureReader artifacts', () => { + let reader; + let uploadedPaths; + let savedEnv; + + // the AllureReader constructor turns these on globally and never restores them + beforeEach(() => { + savedEnv = { + stack: process.env.TESTOMATIO_STACK_PASSED, + steps: process.env.TESTOMATIO_STEPS_PASSED, + }; + + reader = new AllureReader({ apiKey: 'test-api-key' }); + reader.runId = 'run-1'; + uploadedPaths = []; + }); + + afterEach(() => { + if (savedEnv.stack === undefined) delete process.env.TESTOMATIO_STACK_PASSED; + else process.env.TESTOMATIO_STACK_PASSED = savedEnv.stack; + + if (savedEnv.steps === undefined) delete process.env.TESTOMATIO_STEPS_PASSED; + else process.env.TESTOMATIO_STEPS_PASSED = savedEnv.steps; + }); + + function stubUploader(resolve) { + reader.uploader = { + uploadFileByPath: async (filePath, pathInS3) => { + uploadedPaths.push({ filePath, pathInS3 }); + return resolve(filePath); + }, + }; + } + + it('sends uploaded links, not nulls, in the serialized payload', async () => { + const test = { + rid: 'rid-1', + title: 'testCanChangeMeasurementSystem', + files: ['/results/a.png', '/results/b.png', '/results/c.txt'], + }; + reader._tests = [test]; + stubUploader(filePath => `https://bucket.s3.amazonaws.com${filePath}`); + + await reader.uploadArtifacts(); + + const payload = JSON.parse(JSON.stringify(test)); + expect(payload.artifacts).to.deep.equal([ + 'https://bucket.s3.amazonaws.com/results/a.png', + 'https://bucket.s3.amazonaws.com/results/b.png', + 'https://bucket.s3.amazonaws.com/results/c.txt', + ]); + expect(payload.files).to.be.undefined; + }); + + it('drops artifacts that were skipped or failed to upload', async () => { + const test = { + rid: 'rid-2', + title: 'partial upload', + files: ['/results/a.png', '/results/too-big.zip'], + }; + reader._tests = [test]; + stubUploader(filePath => { + if (filePath.endsWith('.zip')) return undefined; + return `https://bucket.s3.amazonaws.com${filePath}`; + }); + + await reader.uploadArtifacts(); + + expect(JSON.parse(JSON.stringify(test)).artifacts).to.deep.equal([ + 'https://bucket.s3.amazonaws.com/results/a.png', + ]); + }); + + it('replaces nested step artifact paths with uploaded links', async () => { + const test = { + rid: 'rid-3', + title: 'step attachments', + files: ['/results/test-level.png'], + steps: [ + { + title: 'outer', + artifacts: ['/results/step-1.png'], + steps: [{ title: 'inner', artifacts: ['/results/step-2.png'] }], + }, + ], + }; + reader._tests = [test]; + stubUploader(filePath => `https://bucket.s3.amazonaws.com${filePath}`); + + await reader.uploadArtifacts(); + + const payload = JSON.parse(JSON.stringify(test)); + // step artifacts render inline in the step tree and stay out of the test-level list + expect(payload.artifacts).to.deep.equal(['https://bucket.s3.amazonaws.com/results/test-level.png']); + expect(payload.steps[0].artifacts).to.deep.equal(['https://bucket.s3.amazonaws.com/results/step-1.png']); + expect(payload.steps[0].steps[0].artifacts).to.deep.equal(['https://bucket.s3.amazonaws.com/results/step-2.png']); + expect(uploadedPaths).to.have.lengthOf(3); + }); + + it('drops step artifacts that failed to upload instead of leaving local paths', async () => { + const test = { + rid: 'rid-4', + title: 'failed step upload', + steps: [{ title: 'outer', artifacts: ['/results/step-1.png'] }], + }; + reader._tests = [test]; + stubUploader(() => undefined); + + await reader.uploadArtifacts(); + + expect(JSON.parse(JSON.stringify(test)).steps[0]).to.not.have.property('artifacts'); + }); + + it('collects step attachments from allure results', () => { + const resultsDir = path.join(os.tmpdir(), `allure-artifacts-${process.pid}`); + fs.mkdirSync(resultsDir, { recursive: true }); + fs.writeFileSync(path.join(resultsDir, 'shot.png'), 'x'); + fs.writeFileSync(path.join(resultsDir, 'page.html'), 'x'); + + const test = reader.processAllureResult( + { + uuid: 'rid-5', + name: 'with step attachments', + status: 'failed', + attachments: [{ source: 'page.html' }], + steps: [ + { + name: 'outer', + status: 'failed', + steps: [{ name: 'inner', status: 'failed', attachments: [{ source: 'shot.png' }] }], + }, + ], + }, + resultsDir, + ); + + expect(test.files).to.deep.equal([path.join(resultsDir, 'page.html')]); + expect(test.steps[0].steps[0].artifacts).to.deep.equal([path.join(resultsDir, 'shot.png')]); + + fs.rmSync(resultsDir, { recursive: true, force: true }); + }); +});