UNPKG

@testomatio/reporter

Version:
468 lines (467 loc) 22.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const debug_1 = __importDefault(require("debug")); const picocolors_1 = __importDefault(require("picocolors")); const gaxios_1 = require("gaxios"); const json_cycle_1 = __importDefault(require("json-cycle")); const constants_js_1 = require("../constants.js"); const utils_js_1 = require("../utils/utils.js"); const pipe_utils_js_1 = require("../utils/pipe_utils.js"); const config_js_1 = require("../config.js"); const debug = (0, debug_1.default)('@testomatio/reporter:pipe:testomatio'); if (process.env.TESTOMATIO_RUN) process.env.runId = process.env.TESTOMATIO_RUN; /** * @typedef {import('../../types/types.js').Pipe} Pipe * @typedef {import('../../types/types.js').TestData} TestData * @class TestomatioPipe * @implements {Pipe} */ class TestomatioPipe { constructor(params, store) { this.batch = { isEnabled: params?.isBatchEnabled ?? !process.env.TESTOMATIO_DISABLE_BATCH_UPLOAD ?? true, intervalFunction: null, // will be created in createRun by setInterval function intervalTime: 5000, // how often tests are sent tests: [], // array of tests in batch batchIndex: 0, // represents the current batch index (starts from 1 and increments by 1 for each batch) numberOfTimesCalledWithoutTests: 0, // how many times batch was called without tests }; this.retriesTimestamps = []; this.reportingCanceledDueToReqFailures = false; this.notReportedTestsCount = 0; this.isEnabled = false; this.url = params.testomatioUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io'; this.apiKey = params.apiKey || config_js_1.config.TESTOMATIO; debug('Testomatio Pipe: ', this.apiKey ? 'API KEY' : '*no api key*'); if (!this.apiKey) { return; } debug('Testomatio Pipe: Enabled'); const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; const proxy = proxyUrl ? new URL(proxyUrl) : null; this.store = store || {}; this.title = params.title || process.env.TESTOMATIO_TITLE; this.sharedRun = !!process.env.TESTOMATIO_SHARED_RUN; this.sharedRunTimeout = !!process.env.TESTOMATIO_SHARED_RUN_TIMEOUT; this.groupTitle = params.groupTitle || process.env.TESTOMATIO_RUNGROUP_TITLE; this.env = process.env.TESTOMATIO_ENV; this.label = process.env.TESTOMATIO_LABEL; // Create a new instance of gaxios with a custom config this.client = new gaxios_1.Gaxios({ baseURL: `${this.url.trim()}`, timeout: constants_js_1.AXIOS_TIMEOUT, proxy: proxy ? proxy.toString() : undefined, retry: true, retryConfig: { retry: constants_js_1.REPORTER_REQUEST_RETRIES.retriesPerRequest, retryDelay: constants_js_1.REPORTER_REQUEST_RETRIES.retryTimeout, httpMethodsToRetry: ['GET', 'PUT', 'HEAD', 'OPTIONS', 'DELETE', 'POST'], shouldRetry: (error) => { if (!error.response) return false; switch (error.response?.status) { case 400: // Bad request (probably wrong API key) case 404: // Test not matched case 429: // Rate limit exceeded case 500: // Internal server error return false; default: break; } return error.response?.status >= 401; // Retry on 401+ and 5xx } } }); this.isEnabled = true; // do not finish this run (for parallel testing) this.proceed = (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_PROCEED); this.jiraId = process.env.TESTOMATIO_JIRA_ID; this.runId = params.runId || process.env.TESTOMATIO_RUN; this.createNewTests = params.createNewTests ?? !!process.env.TESTOMATIO_CREATE; this.hasUnmatchedTests = false; this.requestFailures = 0; if (!(0, utils_js_1.isValidUrl)(this.url.trim())) { this.isEnabled = false; console.error(constants_js_1.APP_PREFIX, picocolors_1.default.red(`Error creating report on Testomat.io, report url '${this.url}' is invalid`)); } } /** * Prepares data for sending to Testomat.io. * @param {*} data - The data to be formatted. * @returns */ #formatData(data) { data.api_key = this.apiKey; data.create = this.createNewTests; // add test ID + run ID if (data.rid) data.rid = `${this.runId}-${data.rid}`; if (!process.env.TESTOMATIO_STACK_PASSED && data.status === constants_js_1.STATUS.PASSED) { data.stack = null; } if (!process.env.TESTOMATIO_STEPS_PASSED && data.status === constants_js_1.STATUS.PASSED) { data.steps = null; } if (process.env.TESTOMATIO_NO_STEPS) { data.steps = null; } return data; } /** * Asynchronously prepares and retrieves the Testomat.io test grepList based on the provided options. * @param {Object} opts - The options for preparing the test grepList. * @returns {Promise<string[]>} - An array containing the retrieved * test grepList, or an empty array if no tests are found or the request is disabled. * @throws {Error} - Throws an error if there was a problem while making the request. */ async prepareRun(opts) { if (!this.isEnabled) return []; const { type, id } = (0, pipe_utils_js_1.parseFilterParams)(opts); try { const q = (0, pipe_utils_js_1.generateFilterRequestParams)({ type, id, apiKey: this.apiKey.trim(), }); if (!q) { return; } const resp = await this.client.request({ method: 'GET', url: '/api/test_grep', ...q, }); if (Array.isArray(resp.data?.tests) && resp.data?.tests?.length > 0) { (0, utils_js_1.foundedTestLog)(constants_js_1.APP_PREFIX, resp.data.tests); return resp.data.tests; } console.log(constants_js_1.APP_PREFIX, `⛔ No tests found for your --filter --> ${type}=${id}`); } catch (err) { console.error(constants_js_1.APP_PREFIX, `🚩 Error getting Testomat.io test grepList: ${err}`); } } /** * Creates a new run on Testomat.io * @param {{isBatchEnabled?: boolean}} params * @returns Promise<void> */ async createRun(params = {}) { this.batch.isEnabled = params.isBatchEnabled ?? this.batch.isEnabled; if (!this.isEnabled) return; if (this.batch.isEnabled && this.isEnabled) this.batch.intervalFunction = setInterval(this.#batchUpload, this.batch.intervalTime); let buildUrl = process.env.BUILD_URL || process.env.CI_JOB_URL || process.env.CIRCLE_BUILD_URL; // GitHub Actions Url if (!buildUrl && process.env.GITHUB_RUN_ID) { // eslint-disable-next-line max-len buildUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; } // Azure DevOps Url if (!buildUrl && process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { const collectionUri = process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI; const project = process.env.SYSTEM_TEAMPROJECT; const buildId = process.env.BUILD_BUILDID; buildUrl = `${collectionUri}/${project}/_build/results?buildId=${buildId}`; } if (buildUrl && !buildUrl.startsWith('http')) buildUrl = undefined; const accessEvent = process.env.TESTOMATIO_PUBLISH ? 'publish' : null; const runParams = Object.fromEntries(Object.entries({ ci_build_url: buildUrl, api_key: this.apiKey.trim(), group_title: this.groupTitle, access_event: accessEvent, jira_id: this.jiraId, env: this.env, title: this.title, label: this.label, shared_run: this.sharedRun, shared_run_timeout: this.sharedRunTimeout, }).filter(([, value]) => !!value)); debug(' >>>>>> Run params', JSON.stringify(runParams, null, 2)); if (this.runId) { this.store.runId = this.runId; debug(`Run with id ${this.runId} already created, updating...`); const resp = await this.client.request({ method: 'PUT', url: `/api/reporter/${this.runId}`, data: runParams, responseType: 'json' }); if (resp.data.artifacts) (0, pipe_utils_js_1.setS3Credentials)(resp.data.artifacts); return; } debug('Creating run...'); try { const resp = await this.client.request({ method: 'POST', url: '/api/reporter', data: runParams, maxContentLength: Infinity, responseType: 'json' }); this.runId = resp.data.uid; this.runUrl = `${this.url}/${resp.data.url.split('/').splice(3).join('/')}`; this.runPublicUrl = resp.data.public_url; if (resp.data.artifacts) (0, pipe_utils_js_1.setS3Credentials)(resp.data.artifacts); this.store.runUrl = this.runUrl; this.store.runPublicUrl = this.runPublicUrl; this.store.runId = this.runId; console.log(constants_js_1.APP_PREFIX, '📊 Report created. Report ID:', this.runId); process.env.runId = this.runId; debug('Run created', this.runId); } catch (err) { const errorText = err.response?.data?.message || err.message; debug('Error creating run', err); console.log(errorText || err); if (!this.apiKey) console.error('Testomat.io API key is not set'); if (!this.apiKey?.startsWith('tstmt')) console.error('Testomat.io API key is invalid'); console.error(constants_js_1.APP_PREFIX, 'Error creating Testomat.io report (see details above), please check if your API key is valid. Skipping report'); printCreateIssue(err); } debug('"createRun" function finished'); } /** * Decides whether to skip test reporting in case of too many request failures * @returns {boolean} */ #cancelTestReportingInCaseOfTooManyReqFailures() { if (!process.env.TESTOMATIO_MAX_REQUEST_FAILURES) return; const cancelReporting = this.requestFailures >= parseInt(process.env.TESTOMATIO_MAX_REQUEST_FAILURES, 10); if (cancelReporting) { this.reportingCanceledDueToReqFailures = true; let errorMessage = `⚠️ ${process.env.TESTOMATIO_MAX_REQUEST_FAILURES}`; errorMessage += ' requests were failed, reporting to Testomat aborted.'; console.warn(`${constants_js_1.APP_PREFIX} ${picocolors_1.default.yellow(errorMessage)}`); } return cancelReporting; } #uploadSingleTest = async (data) => { if (!this.isEnabled) return; if (!this.runId) return; if (this.#cancelTestReportingInCaseOfTooManyReqFailures()) return; this.#formatData(data); const json = json_cycle_1.default.stringify(data); debug('Adding test', json); return this.client.request({ method: 'POST', url: `/api/reporter/${this.runId}/testrun`, data: json, headers: { 'Content-Type': 'application/json', }, maxContentLength: Infinity }).catch(err => { this.requestFailures++; this.notReportedTestsCount++; if (err.response) { if (err.response.status >= 400) { const responseData = err.response.data || { message: '' }; console.log(constants_js_1.APP_PREFIX, picocolors_1.default.yellow(`Warning: ${responseData.message} (${err.response.status})`), picocolors_1.default.gray(data?.title || '')); if (err.response?.data?.message?.includes('could not be matched')) { this.hasUnmatchedTests = true; } return; } console.log(constants_js_1.APP_PREFIX, picocolors_1.default.yellow(`Warning: ${data?.title || ''} (${err.response?.status})`), `Report couldn't be processed: ${err?.response?.data?.message}`); printCreateIssue(err); } else { console.log(constants_js_1.APP_PREFIX, picocolors_1.default.blue(data?.title || ''), "Report couldn't be processed", err); } }); }; /** * Uploads tests as a batch (multiple tests at once). Intended to be used with a setInterval */ #batchUpload = async () => { if (!this.batch.isEnabled) return; if (!this.batch.tests.length) return; if (this.#cancelTestReportingInCaseOfTooManyReqFailures()) return; // prevent infinite loop if (this.batch.numberOfTimesCalledWithoutTests > 10) { debug('📨 Batch upload: no tests to send for 10 times, stopping batch'); clearInterval(this.batch.intervalFunction); this.batch.isEnabled = false; } if (!this.batch.tests.length) { debug('📨 Batch upload: no tests to send'); this.batch.numberOfTimesCalledWithoutTests++; return; } this.batch.batchIndex++; // get tests from batch and clear batch const testsToSend = this.batch.tests.splice(0); debug('📨 Batch upload', testsToSend.length, 'tests'); return this.client.request({ method: 'POST', url: `/api/reporter/${this.runId}/testrun`, data: { api_key: this.apiKey, tests: testsToSend, batch_index: this.batch.batchIndex }, headers: { 'Content-Type': 'application/json', }, maxContentLength: Infinity }).catch(err => { this.requestFailures++; this.notReportedTestsCount += testsToSend.length; if (err.response) { if (err.response.status >= 400) { const responseData = err.response.data || { message: '' }; console.log(constants_js_1.APP_PREFIX, picocolors_1.default.yellow(`Warning: ${responseData.message} (${err.response.status})`)); if (err.response?.data?.message?.includes('could not be matched')) { this.hasUnmatchedTests = true; } return; } console.log(constants_js_1.APP_PREFIX, picocolors_1.default.yellow(`Warning: (${err.response?.status})`), `Report couldn't be processed: ${err?.response?.data?.message}`); printCreateIssue(err); } else { console.log(constants_js_1.APP_PREFIX, "Report couldn't be processed", err); } }); }; /** * Adds a test to the batch uploader (or reports a single test if batch uploading is disabled) */ addTest(data) { this.isEnabled = !!(this.apiKey ?? this.isEnabled); if (!this.isEnabled) return; this.runId = this.runId || process.env.runId || this.store.runId || (0, utils_js_1.readLatestRunId)(); if (!this.runId) { console.warn(constants_js_1.APP_PREFIX, picocolors_1.default.red('Run ID is not set, skipping test reporting')); return; } this.#formatData(data); let uploading = null; if (!this.batch.isEnabled) uploading = this.#uploadSingleTest(data); else this.batch.tests.push(data); // if test is added after run which is already finished if (!this.batch.intervalFunction) uploading = this.#batchUpload(); // return promise to be able to wait for it return uploading; } /** * @param {import('../../types/types.js').RunData} params * @returns */ async finishRun(params) { if (!this.isEnabled) return; await this.#batchUpload(); if (this.batch.intervalFunction) { clearInterval(this.batch.intervalFunction); // this code is required in case test is added after run is finished // (e.g. if test has artifacts, add test function will be invoked only after artifacts are uploaded) // batch stops working after run is finished; thus, disable it to use single test uploading this.batch.intervalFunction = null; this.batch.isEnabled = false; } debug('Finishing run...'); if (this.reportingCanceledDueToReqFailures) { const errorMessage = picocolors_1.default.red(`⚠️ Due to request failures, ${this.notReportedTestsCount} test(s) were not reported to Testomat.io`); console.warn(`${constants_js_1.APP_PREFIX} ${errorMessage}`); } const { status } = params; let status_event; if (status === constants_js_1.STATUS.FINISHED) status_event = 'finish'; if (status === constants_js_1.STATUS.PASSED) status_event = 'pass'; if (status === constants_js_1.STATUS.FAILED) status_event = 'fail'; try { if (this.runId && !this.proceed) { await this.client.request({ method: 'PUT', url: `/api/reporter/${this.runId}`, data: { api_key: this.apiKey, duration: params.duration, status_event, detach: params.detach, tests: params.tests, } }); if (this.runUrl) { console.log(constants_js_1.APP_PREFIX, '📊 Report Saved. Report URL:', picocolors_1.default.magenta(this.runUrl)); } if (this.runPublicUrl) { console.log(constants_js_1.APP_PREFIX, '🌟 Public URL:', picocolors_1.default.magenta(this.runPublicUrl)); } } if (this.runUrl && this.proceed) { const notFinishedMessage = picocolors_1.default.yellow(picocolors_1.default.bold('Run was not finished because of $TESTOMATIO_PROCEED')); console.log(constants_js_1.APP_PREFIX, `📊 ${notFinishedMessage}. Report URL: ${picocolors_1.default.magenta(this.runUrl)}`); console.log(constants_js_1.APP_PREFIX, `🛬 Run to finish it: TESTOMATIO_RUN=${this.runId} npx @testomatio/reporter finish`); } if (this.hasUnmatchedTests) { console.log(''); console.log(constants_js_1.APP_PREFIX, picocolors_1.default.yellow(picocolors_1.default.bold('⚠️ Some reported tests were not found in Testomat.io project'))); console.log(constants_js_1.APP_PREFIX, `If you use Testomat.io as a reporter only, please re-run tests using ${picocolors_1.default.bold('TESTOMATIO_CREATE=1')}`); console.log(constants_js_1.APP_PREFIX, `But to keep your tests consistent it is recommended to ${picocolors_1.default.bold('import tests first')}`); console.log(constants_js_1.APP_PREFIX, 'If tests were imported but still not matched, assign test IDs to your tests.'); console.log(constants_js_1.APP_PREFIX, 'You can do that automatically via command line tools:'); console.log(constants_js_1.APP_PREFIX, picocolors_1.default.bold('npx check-tests ... --update-ids'), 'See: https://bit.ly/js-update-ids'); console.log(constants_js_1.APP_PREFIX, 'or for Cucumber:'); console.log(constants_js_1.APP_PREFIX, picocolors_1.default.bold('npx check-cucumber ... --update-ids'), 'See: https://bit.ly/bdd-update-ids'); } } catch (err) { console.log(constants_js_1.APP_PREFIX, 'Error updating status, skipping...', err); printCreateIssue(err); } debug('Run finished'); } toString() { return 'Testomatio Reporter'; } } let registeredErrorHints = false; function printCreateIssue(err) { if (registeredErrorHints) return; registeredErrorHints = true; process.on('exit', () => { console.log(); console.log(constants_js_1.APP_PREFIX, 'There was an error reporting to Testomat.io:'); console.log(constants_js_1.APP_PREFIX, 'If you think this is a bug please create an issue: https://github.com/testomatio/reporter/issues/new'); console.log(constants_js_1.APP_PREFIX, 'Provide this information:'); console.log('Error:', err.message || err.code); if (!err.config) return; const time = new Date().toUTCString(); const { body, url, baseURL, method } = err?.config || {}; console.log('```js'); console.log({ body: body?.replace(/"(tstmt_[^"]+)"/g, 'tstmt_*'), url, baseURL, method, time }); console.log('```'); }); } module.exports = TestomatioPipe;