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
94 changes: 70 additions & 24 deletions src/allureReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<object>|undefined} steps
* @param {string} runId
* @param {string} rid
* @returns {Promise<number>} 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}`);
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/xmlReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}

Expand Down
151 changes: 151 additions & 0 deletions tests/unit/allure_artifacts_test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading