diff --git a/package-lock.json b/package-lock.json index e4b1c34..1d7bbb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "testbeats", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "testbeats", - "version": "2.0.2", + "version": "2.0.3", "license": "ISC", "dependencies": { "async-retry": "^1.3.3", "dotenv": "^14.3.2", + "form-data-lite": "^1.0.3", "influxdb-lite": "^1.0.0", "performance-results-parser": "latest", "phin-retry": "^1.0.3", @@ -273,7 +274,6 @@ }, "node_modules/asynckit": { "version": "0.4.0", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -509,7 +509,6 @@ }, "node_modules/combined-stream": { "version": "1.0.8", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -613,7 +612,6 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -717,9 +715,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz", - "integrity": "sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz", + "integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==", "funding": [ { "type": "github", @@ -793,8 +791,8 @@ }, "node_modules/form-data-lite": { "version": "1.0.3", - "dev": true, - "license": "ISC", + "resolved": "https://registry.npmjs.org/form-data-lite/-/form-data-lite-1.0.3.tgz", + "integrity": "sha512-P7xPqAiOPKzC9Q9aywAZJCQq4QOE5WokPb3HrcWRh7C57RKytueJzoORZAVgHBNvK/lL7E+FxjQjd4X/zbecEQ==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -1364,7 +1362,6 @@ }, "node_modules/mime-lite": { "version": "1.0.3", - "dev": true, "license": "MIT" }, "node_modules/mimic-response": { @@ -2362,14 +2359,14 @@ } }, "node_modules/test-results-parser": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/test-results-parser/-/test-results-parser-0.1.6.tgz", - "integrity": "sha512-KMTgEZyM4OMqvy2Ofc14l0mHRvxSZuRNv06tE2RxKtyAWwt6Y1LE2H1GDiHwTxBbf1nGsY+8C5BUJ7wTxfMTaQ==", + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/test-results-parser/-/test-results-parser-0.1.18.tgz", + "integrity": "sha512-Dszxhk5qdQ+3mj9wqloYTN4bKHbjL0nO08dYv4TtZ/UnEzXMC0j4u6mZR9iX5zCTVycyWXZtn1boIASUUgiP+Q==", "dependencies": { "fast-xml-parser": "^4.3.2", "globrex": "^0.1.2", "html-escaper": "^3.0.3", - "totalist": "^3.0.0" + "totalist": "^3.0.1" } }, "node_modules/test-results-parser/node_modules/html-escaper": { @@ -2397,8 +2394,9 @@ } }, "node_modules/totalist": { - "version": "3.0.0", - "license": "MIT", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "engines": { "node": ">=6" } @@ -2738,8 +2736,7 @@ } }, "asynckit": { - "version": "0.4.0", - "dev": true + "version": "0.4.0" }, "at-least-node": { "version": "1.0.0", @@ -2881,7 +2878,6 @@ }, "combined-stream": { "version": "1.0.8", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -2947,8 +2943,7 @@ "dev": true }, "delayed-stream": { - "version": "1.0.0", - "dev": true + "version": "1.0.0" }, "delegates": { "version": "1.0.0", @@ -3007,9 +3002,9 @@ } }, "fast-xml-parser": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz", - "integrity": "sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz", + "integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==", "requires": { "strnum": "^1.0.5" } @@ -3050,7 +3045,8 @@ }, "form-data-lite": { "version": "1.0.3", - "dev": true, + "resolved": "https://registry.npmjs.org/form-data-lite/-/form-data-lite-1.0.3.tgz", + "integrity": "sha512-P7xPqAiOPKzC9Q9aywAZJCQq4QOE5WokPb3HrcWRh7C57RKytueJzoORZAVgHBNvK/lL7E+FxjQjd4X/zbecEQ==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -3403,8 +3399,7 @@ } }, "mime-lite": { - "version": "1.0.3", - "dev": true + "version": "1.0.3" }, "mimic-response": { "version": "2.1.0", @@ -4028,14 +4023,14 @@ } }, "test-results-parser": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/test-results-parser/-/test-results-parser-0.1.6.tgz", - "integrity": "sha512-KMTgEZyM4OMqvy2Ofc14l0mHRvxSZuRNv06tE2RxKtyAWwt6Y1LE2H1GDiHwTxBbf1nGsY+8C5BUJ7wTxfMTaQ==", + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/test-results-parser/-/test-results-parser-0.1.18.tgz", + "integrity": "sha512-Dszxhk5qdQ+3mj9wqloYTN4bKHbjL0nO08dYv4TtZ/UnEzXMC0j4u6mZR9iX5zCTVycyWXZtn1boIASUUgiP+Q==", "requires": { "fast-xml-parser": "^4.3.2", "globrex": "^0.1.2", "html-escaper": "^3.0.3", - "totalist": "^3.0.0" + "totalist": "^3.0.1" }, "dependencies": { "html-escaper": { @@ -4057,7 +4052,9 @@ } }, "totalist": { - "version": "3.0.0" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" }, "tr46": { "version": "0.0.3", diff --git a/package.json b/package.json index 57ae143..3d1d5cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "testbeats", - "version": "2.0.2", + "version": "2.0.3", "description": "Publish test results to Microsoft Teams, Google Chat, Slack and InfluxDB", "main": "src/index.js", "types": "./src/index.d.ts", @@ -48,6 +48,7 @@ "dependencies": { "async-retry": "^1.3.3", "dotenv": "^14.3.2", + "form-data-lite": "^1.0.3", "influxdb-lite": "^1.0.0", "performance-results-parser": "latest", "phin-retry": "^1.0.3", diff --git a/src/beats/beats.api.js b/src/beats/beats.api.js index f2250d3..eca7e72 100644 --- a/src/beats/beats.api.js +++ b/src/beats/beats.api.js @@ -32,6 +32,17 @@ class BeatsApi { }); } + uploadAttachments(headers, payload) { + return request.post({ + url: `${this.getBaseUrl()}/api/core/v1/test-cases/attachments`, + headers: { + 'x-api-key': this.config.api_key, + ...headers + }, + body: payload + }); + } + getBaseUrl() { return process.env.TEST_BEATS_URL || "https://app.testbeats.com"; } diff --git a/src/beats/beats.attachments.js b/src/beats/beats.attachments.js new file mode 100644 index 0000000..839c151 --- /dev/null +++ b/src/beats/beats.attachments.js @@ -0,0 +1,100 @@ +const fs = require('fs'); +const path = require('path'); +const FormData = require('form-data-lite'); +const TestResult = require('test-results-parser/src/models/TestResult'); +const { BeatsApi } = require('./beats.api'); +const logger = require('../utils/logger'); + +const MAX_ATTACHMENTS_PER_REQUEST = 5; +const MAX_ATTACHMENTS_PER_RUN = 20; +const MAX_ATTACHMENT_SIZE = 1024 * 1024; + +class BeatsAttachments { + + /** + * @param {import('../index').PublishReport} config + * @param {TestResult} result + */ + constructor(config, result, test_run_id) { + this.config = config; + this.result = result; + this.api = new BeatsApi(config); + this.test_run_id = test_run_id; + this.failed_test_cases = []; + this.attachments = []; + } + + async upload() { + this.#setAllFailedTestCases(); + this.#setAttachments(); + await this.#uploadAttachments(); + } + + #setAllFailedTestCases() { + for (const suite of this.result.suites) { + for (const test of suite.cases) { + if (test.status === 'FAIL') { + this.failed_test_cases.push(test); + } + } + } + } + + #setAttachments() { + for (const test_case of this.failed_test_cases) { + for (const attachment of test_case.attachments) { + this.attachments.push(attachment); + } + } + } + + async #uploadAttachments() { + if (this.attachments.length === 0) { + return; + } + logger.info(`⏳ Uploading ${this.attachments.length} attachments...`); + const result_file = this.config.results[0].files[0]; + const result_file_dir = path.dirname(result_file); + try { + let count = 0; + const size = MAX_ATTACHMENTS_PER_REQUEST; + for (let i = 0; i < this.attachments.length; i += size) { + if (count >= MAX_ATTACHMENTS_PER_RUN) { + logger.warn('⚠️ Maximum number of attachments per run reached. Skipping remaining attachments.'); + break; + } + const attachments_subset = this.attachments.slice(i, i + size); + const form = new FormData(); + const file_images = [] + for (const attachment of attachments_subset) { + const attachment_path = path.join(result_file_dir, attachment.path); + const stats = fs.statSync(attachment_path); + if (stats.size > MAX_ATTACHMENT_SIZE) { + logger.warn(`⚠️ Attachment ${attachment.path} is too big (${stats.size} bytes). Allowed size is ${MAX_ATTACHMENT_SIZE} bytes.`); + continue; + } + form.append('images', fs.readFileSync(attachment_path)); + form.append('test_run_id', this.test_run_id); + file_images.push({ + file_name: attachment.name, + file_path: attachment.path, + }); + count += 1; + } + if (file_images.length === 0) { + return; + } + form.append('file_images', JSON.stringify(file_images)); + await this.api.uploadAttachments(form.getHeaders(), form.getBuffer()); + logger.info(`🏞️ Uploaded ${count} attachments`); + } + } catch (error) { + logger.error(`❌ Unable to upload attachments: ${error.message}`, error); + } + } + + + +} + +module.exports = { BeatsAttachments } \ No newline at end of file diff --git a/src/beats/beats.js b/src/beats/beats.js index c2887f9..3d3afe1 100644 --- a/src/beats/beats.js +++ b/src/beats/beats.js @@ -2,6 +2,8 @@ const { getCIInformation } = require('../helpers/ci'); const logger = require('../utils/logger'); const { BeatsApi } = require('./beats.api'); const { HOOK } = require('../helpers/constants'); +const TestResult = require('test-results-parser/src/models/TestResult'); +const { BeatsAttachments } = require('./beats.attachments'); class Beats { @@ -22,6 +24,7 @@ class Beats { this.#setRunName(); this.#setApiKey(); await this.#publishTestResults(); + await this.#uploadAttachments(); this.#updateTitleLink(); await this.#attachFailureSummary(); } @@ -69,6 +72,33 @@ class Beats { return payload; } + async #uploadAttachments() { + if (!this.test_run_id) { + return; + } + if (this.result.status !== 'FAIL') { + return; + } + try { + const attachments = new BeatsAttachments(this.config, this.result, this.test_run_id); + await attachments.upload(); + } catch (error) { + logger.error(`❌ Unable to upload attachments: ${error.message}`, error); + } + } + + #getAllFailedTestCases() { + const test_cases = []; + for (const suite of this.result.suites) { + for (const test of suite.cases) { + if (test.status === 'FAIL') { + test_cases.push(test); + } + } + } + return test_cases; + } + #updateTitleLink() { if (!this.test_run_id) { return; diff --git a/src/commands/publish.js b/src/commands/publish.js index 65e192a..3669fc9 100644 --- a/src/commands/publish.js +++ b/src/commands/publish.js @@ -12,7 +12,7 @@ const logger = require('../utils/logger'); * @param {import('../index').PublishOptions} opts */ async function run(opts) { - logger.info(`💡 TestBeats v${pkg.version}`); + logger.info(`🥁 TestBeats v${pkg.version}`); if (!opts) { throw new Error('Missing publish options'); } @@ -46,7 +46,7 @@ async function run(opts) { * @param {import('../index').PublishReport} report */ async function processReport(report) { - logger.debug("processReport: Started") + logger.info('🧙 Processing results...'); const parsed_results = []; for (const result_options of report.results) { if (result_options.type === 'custom') { @@ -66,10 +66,10 @@ async function processReport(report) { await target_manager.run(target, result); } } else { - logger.warn('No targets defined, skipping sending results to targets'); + logger.warn('⚠️ No targets defined, skipping sending results to targets'); } } - logger.debug("processReport: Ended") + logger.debug('✔️ Results processed successfully!'); } /** diff --git a/test/beats.spec.js b/test/beats.spec.js index fdde806..fb502f8 100644 --- a/test/beats.spec.js +++ b/test/beats.spec.js @@ -74,4 +74,38 @@ describe('TestBeats', () => { assert.equal(mock.getInteraction(id3).exercised, true); }); + it('should send results with attachments to beats', async () => { + const id1 = mock.addInteraction('post test results to beats'); + const id2 = mock.addInteraction('get test results from beats'); + const id3 = mock.addInteraction('upload attachments'); + const id4 = mock.addInteraction('post test-summary to teams with strict as false'); + await publish({ + config: { + api_key: 'api-key', + project: 'project-name', + run: 'build-name', + targets: [ + { + name: 'teams', + inputs: { + url: 'http://localhost:9393/message' + } + } + ], + results: [ + { + type: 'junit', + files: [ + 'test/data/playwright/junit.xml' + ] + } + ] + } + }); + assert.equal(mock.getInteraction(id1).exercised, true); + assert.equal(mock.getInteraction(id2).exercised, true); + assert.equal(mock.getInteraction(id3).exercised, true); + assert.equal(mock.getInteraction(id4).exercised, true); + }); + }); \ No newline at end of file diff --git a/test/data/playwright/example-get-started-link-chromium/test-failed-1.png b/test/data/playwright/example-get-started-link-chromium/test-failed-1.png new file mode 100644 index 0000000..24b1596 Binary files /dev/null and b/test/data/playwright/example-get-started-link-chromium/test-failed-1.png differ diff --git a/test/data/playwright/example-get-started-link-firefox/test-failed-1.png b/test/data/playwright/example-get-started-link-firefox/test-failed-1.png new file mode 100644 index 0000000..d2dbabe Binary files /dev/null and b/test/data/playwright/example-get-started-link-firefox/test-failed-1.png differ diff --git a/test/data/playwright/junit.xml b/test/data/playwright/junit.xml new file mode 100644 index 0000000..bc7329a --- /dev/null +++ b/test/data/playwright/junit.xml @@ -0,0 +1,76 @@ + + + + + + + Call log: + - expect.toBeVisible with timeout 5000ms + - waiting for getByRole('heading', { name: 'Installations' }) + + + 15 | + 16 | // Expects page to have a heading with the name of Installation. + > 17 | await expect(page.getByRole('heading', { name: 'Installations' })).toBeVisible(); + | ^ + 18 | }); + 19 | + + at /Users/anudeep/Documents/my/repos/test-results-reporter/example-playwright-testbeats/tests/example.spec.ts:17:70 + + attachment #1: screenshot (image/png) ────────────────────────────────────────────────────────── + test-results/example-get-started-link-chromium/test-failed-1.png + ──────────────────────────────────────────────────────────────────────────────────────────────── +]]> + + + + + + + + + + + + Call log: + - expect.toBeVisible with timeout 5000ms + - waiting for getByRole('heading', { name: 'Installations' }) + + + 15 | + 16 | // Expects page to have a heading with the name of Installation. + > 17 | await expect(page.getByRole('heading', { name: 'Installations' })).toBeVisible(); + | ^ + 18 | }); + 19 | + + at /Users/anudeep/Documents/my/repos/test-results-reporter/example-playwright-testbeats/tests/example.spec.ts:17:70 + + attachment #1: screenshot (image/png) ────────────────────────────────────────────────────────── + test-results/example-get-started-link-firefox/test-failed-1.png + ──────────────────────────────────────────────────────────────────────────────────────────────── +]]> + + + + + + + \ No newline at end of file diff --git a/test/mocks/beats.mock.js b/test/mocks/beats.mock.js index 89f9665..659b9d8 100644 --- a/test/mocks/beats.mock.js +++ b/test/mocks/beats.mock.js @@ -1,4 +1,5 @@ const { addInteractionHandler } = require('pactum').handler; +const { like, includes } = require('pactum-matchers'); addInteractionHandler('post test results to beats', () => { return { @@ -39,4 +40,17 @@ addInteractionHandler('get test results from beats', () => { } } } -}); \ No newline at end of file +}); + +addInteractionHandler('upload attachments', () => { + return { + strict: false, + request: { + method: 'POST', + path: '/api/core/v1/test-cases/attachments', + }, + response: { + status: 200, + } + } +}) \ No newline at end of file diff --git a/test/mocks/teams.mock.js b/test/mocks/teams.mock.js index 990e077..7f7e9e1 100644 --- a/test/mocks/teams.mock.js +++ b/test/mocks/teams.mock.js @@ -1574,4 +1574,17 @@ addInteractionHandler('post test-summary with beats to teams with ai failure sum status: 200 } } +}); + +addInteractionHandler('post test-summary to teams with strict as false', () => { + return { + strict: false, + request: { + method: 'POST', + path: '/message', + }, + response: { + status: 200 + } + } }); \ No newline at end of file