UNPKG

@testomatio/reporter

Version:
450 lines (448 loc) 21.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Client = void 0; const debug_1 = __importDefault(require("debug")); const callsite_record_1 = __importDefault(require("callsite-record")); const minimatch_1 = require("minimatch"); const fs_1 = __importDefault(require("fs")); const picocolors_1 = __importDefault(require("picocolors")); const crypto_1 = require("crypto"); const constants_js_1 = require("./constants.js"); const index_js_1 = require("./pipe/index.js"); const glob_1 = require("glob"); const path_1 = __importStar(require("path")); const node_url_1 = require("node:url"); const uploader_js_1 = require("./uploader.js"); const utils_js_1 = require("./utils/utils.js"); const filesize_1 = require("filesize"); const util_1 = require("util"); const debug = (0, debug_1.default)('@testomatio/reporter:client'); const stripColors = util_1.stripVTControlCharacters || ((str) => str?.replace(/\x1b\[[0-9;]*m/g, '') || ''); // removed __dirname usage, because: // 1. replaced with ESM syntax (import.meta.url), but it throws an error on tsc compilation; // 2. got error "__dirname already defined" in compiles js code (cjs dir) let listOfTestFilesToExcludeFromReport = null; /** * @typedef {import('../types/types.js').TestData} TestData * @typedef {import('../types/types.js').PipeResult} PipeResult */ class Client { /** * Create a Testomat client instance * @returns */ constructor(params = {}) { this.paramsForPipesFactory = params; this.pipeStore = {}; this.runId = ''; this.queue = Promise.resolve(); // @ts-ignore this line will be removed in compiled code, because __dirname is defined in commonjs const pathToPackageJSON = path_1.default.join(__dirname, '../package.json'); try { this.version = JSON.parse(fs_1.default.readFileSync(pathToPackageJSON).toString()).version; console.log(constants_js_1.APP_PREFIX, `Testomatio Reporter v${this.version}`); } catch (e) { // do nothing } this.executionList = Promise.resolve(); this.uploader = new uploader_js_1.S3Uploader(); } /** * Asynchronously prepares the execution list for running tests through various pipes. * Each pipe in the client is checked for enablement, * and if all pipes are disabled, the function returns a resolved Promise. * Otherwise, it executes the `prepareRun` method for each enabled pipe and collects the results. * The results are then filtered to remove any undefined values. * If no valid results are found, the function returns undefined. * Otherwise, it returns the first non-empty array from the filtered results. * * @param {Object} params - The options for preparing the test execution list. * @param {string} params.pipe - Name of the executed pipe. * @param {string} params.pipeOptions - Filter option. * @returns {Promise<any>} - A Promise that resolves to an * array containing the prepared execution list, * or resolves to undefined if no valid results are found or if all pipes are disabled. */ async prepareRun(params) { this.pipes = await (0, index_js_1.pipesFactory)(params || this.paramsForPipesFactory || {}, this.pipeStore); const { pipe, pipeOptions } = params; // all pipes disabled, skipping if (!this.pipes.some(p => p.isEnabled)) { return Promise.resolve(); } try { const filterPipe = this.pipes.find(p => p.constructor.name.toLowerCase() === `${pipe.toLowerCase()}pipe`); if (!filterPipe?.isEnabled) { // TODO:for the future for the another pipes console.warn(constants_js_1.APP_PREFIX, `At the moment processing is available only for the "testomatio" key. Example: "testomatio:tag-name=xxx"`); return; } const results = await Promise.all(this.pipes.map(async (p) => ({ pipe: p.toString(), result: await p.prepareRun(pipeOptions) }))); const result = results.filter(p => p.pipe.includes('Testomatio'))[0]?.result; if (!result || result.length === 0) { return; } debug('Execution tests list', result); return result; } catch (err) { console.error(constants_js_1.APP_PREFIX, err); } } /** * Used to create a new Test run * * @returns {Promise<any>} - resolves to Run id which should be used to update / add test */ async createRun(params) { if (!this.pipes || !this.pipes.length) this.pipes = await (0, index_js_1.pipesFactory)(params || this.paramsForPipesFactory || {}, this.pipeStore); debug('Creating run...'); // all pipes disabled, skipping if (!this.pipes?.filter(p => p.isEnabled).length) return Promise.resolve(); this.queue = this.queue .then(() => Promise.all(this.pipes.map(p => p.createRun()))) .catch(err => console.log(constants_js_1.APP_PREFIX, err)) .then(() => { const runId = this.pipeStore?.runId; if (runId) this.runId = runId; (0, utils_js_1.storeRunId)(this.runId); }) .then(() => this.uploader.checkEnabled()) .then(() => undefined); // fixes return type // debug('Run', this.queue); return this.queue; } /** * Updates test status and its data * * @param {string|undefined} status * @param {TestData} [testData] * @returns {Promise<PipeResult[]>} */ async addTestRun(status, testData) { if (!testData) testData = { title: 'Unknown test', suite_title: 'Unknown suite', }; // Add timestamp if not already present (microseconds since Unix epoch) if (!testData.timestamp && !process.env.TESTOMATIO_NO_TIMESTAMP) { testData.timestamp = Math.floor((performance.timeOrigin + performance.now()) * 1000); } /** * @type {TestData} */ const { rid, error = null, steps: originalSteps, title, suite_title, } = testData; let steps = originalSteps; const uploadedFiles = []; const stackArtifactsEnabled = (0, utils_js_1.transformEnvVarToBoolean)(process.env.TESTOMATIO_STACK_ARTIFACTS); const { time = 0, example = null, files = [], filesBuffers = [], code = null, file, suite_id, test_id, timestamp, links, manuallyAttachedArtifacts, overwrite, tags, } = testData; let { message = '', meta = {} } = testData; meta = Object.entries(meta) .filter(([, value]) => value !== null && value !== undefined) .reduce((acc, [key, value]) => { if (key) acc[key] = value; return acc; }, {}); const testContext = suite_title ? `${suite_title} ${title}` : title; let errorFormatted = ''; if (error) { errorFormatted += this.formatError(error) || ''; message = error?.message; } let fullLogs = this.formatLogs({ error: errorFormatted, steps, logs: testData.logs }); if (stackArtifactsEnabled && fullLogs?.trim()?.length > 0) { uploadedFiles.push(this.uploader.uploadFileAsBuffer(Buffer.from(stripColors(fullLogs), 'utf8'), [this.runId, rid, `logs_${+new Date}.log`])); fullLogs = ''; steps = null; } if (!this.pipes || !this.pipes.length) this.pipes = await (0, index_js_1.pipesFactory)(this.paramsForPipesFactory || {}, this.pipeStore); if (!this.pipes?.filter(p => p.isEnabled).length) { if (uploadedFiles.length > 0) { await Promise.all(uploadedFiles); } return []; } if (isTestShouldBeExculedFromReport(testData)) return []; if (status === constants_js_1.STATUS.SKIPPED && process.env.TESTOMATIO_EXCLUDE_SKIPPED) { debug('Skipping test from report', testData?.title); return []; } if (manuallyAttachedArtifacts?.length) files.push(...manuallyAttachedArtifacts); for (let f of files) { if (!f) continue; // f === null if (typeof f === 'object') { if (!f.path) continue; f = f.path; } uploadedFiles.push(this.uploader.uploadFileByPath(f, [this.runId, rid, path_1.default.basename(f)])); } for (const [idx, buffer] of filesBuffers.entries()) { const fileName = `${idx + 1}-${title.replace(/\s+/g, '-')}`; uploadedFiles.push(this.uploader.uploadFileAsBuffer(buffer, [this.runId, rid, fileName])); } const artifacts = (await Promise.all(uploadedFiles)).filter(n => !!n); const workspaceDir = process.env.TESTOMATIO_WORKDIR || process.cwd(); const relativeFile = file ? path_1.default.relative(workspaceDir, file) : file; const rootSuiteId = (0, utils_js_1.validateSuiteId)(process.env.TESTOMATIO_SUITE); const data = { rid, files, steps, status, stack: fullLogs, example, file: relativeFile, code, title, suite_title, suite_id, test_id, message, run_time: typeof time === 'number' ? time : parseFloat(time), timestamp, artifacts, meta, links, overwrite, tags, ...(rootSuiteId && { root_suite_id: rootSuiteId }), }; // debug('Adding test run...', data); // @ts-ignore this.queue = this.queue.then(() => Promise.all(this.pipes.map(async (pipe) => { try { const result = await pipe.addTest(data); return { pipe: pipe.toString(), result }; } catch (err) { console.log(constants_js_1.APP_PREFIX, pipe.toString(), err); } }))); // @ts-ignore return this.queue; } /** * * Updates the status of the current test run and finishes the run. * @param {'passed' | 'failed' | 'skipped' | 'finished'} status - The status of the current test run. * Must be one of "passed", "failed", or "finished" * @returns {Promise<any>} - A Promise that resolves when finishes the run. */ async updateRunStatus(status) { this.pipes ||= await (0, index_js_1.pipesFactory)(this.paramsForPipesFactory || {}, this.pipeStore); this.runId ||= (0, utils_js_1.readLatestRunId)(); debug('Updating run status...'); // all pipes disabled, skipping if (!this.pipes?.filter(p => p.isEnabled).length) return Promise.resolve(); const runParams = { status }; this.queue = this.queue .then(() => Promise.all(this.pipes.map(p => p.finishRun(runParams)))) .then(() => { if (!this.uploader.isEnabled) return; const filesizeStrMaxLength = 7; if (this.uploader.successfulUploads.length) { debug('\n', constants_js_1.APP_PREFIX, `🗄️ ${this.uploader.successfulUploads.length} artifacts uploaded to S3 bucket`); const uploadedArtifacts = this.uploader.successfulUploads.map(file => ({ relativePath: file.path.replace(process.cwd(), ''), link: file.link, sizePretty: file.size == null ? 'unknown' : (0, filesize_1.filesize)(file.size, { round: 0 }).toString(), })); uploadedArtifacts.forEach(upload => { debug(`🟢Uploaded artifact`, `${upload.relativePath},`, 'size:', `${upload.sizePretty},`, 'link:', `${upload.link}`); }); } if (this.uploader.failedUploads.length) { console.log(constants_js_1.APP_PREFIX, `🗄️ ${this.uploader.failedUploads.length} artifacts 🔴${picocolors_1.default.bold('failed')} to upload`); const failedUploads = this.uploader.failedUploads.map(file => ({ relativePath: file.path.replace(process.cwd(), ''), sizePretty: file.size == null ? 'unknown' : (0, filesize_1.filesize)(file.size, { round: 0 }).toString(), })); const pathPadding = Math.max(...failedUploads.map(upload => upload.relativePath.length)) + 1; failedUploads.forEach(upload => { console.log(` ${picocolors_1.default.gray('|')} 🔴 ${upload.relativePath.padEnd(pathPadding)} ${picocolors_1.default.gray(`| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`)}`); }); } if (this.uploader.skippedUploads.length) { console.log('\n', constants_js_1.APP_PREFIX, `🗄️ ${picocolors_1.default.bold(this.uploader.skippedUploads.length)} artifacts uploading 🟡${picocolors_1.default.bold('skipped')}`); const skippedUploads = this.uploader.skippedUploads.map(file => ({ relativePath: file.path.replace(process.cwd(), ''), sizePretty: file.size === null ? 'unknown' : (0, filesize_1.filesize)(file.size, { round: 0 }).toString(), })); const pathPadding = Math.max(...skippedUploads.map(upload => upload.relativePath.length)) + 1; skippedUploads.forEach(upload => { console.log(` ${picocolors_1.default.gray('|')} 🟡 ${upload.relativePath.padEnd(pathPadding)} ${picocolors_1.default.gray(`| ${upload.sizePretty.padStart(filesizeStrMaxLength)} |`)}`); }); } if (this.uploader.skippedUploads.length || this.uploader.failedUploads.length) { const command = `TESTOMATIO=<your_api_key> TESTOMATIO_RUN=${this.runId} npx @testomatio/reporter upload-artifacts`; const numberOfNotUploadedArtifacts = this.uploader.skippedUploads.length + this.uploader.failedUploads.length; console.log(constants_js_1.APP_PREFIX, `${numberOfNotUploadedArtifacts} artifacts were not uploaded. Run "${picocolors_1.default.magenta(command)}" with valid S3 credentials to upload skipped & failed artifacts`); } }) .catch(err => console.log(constants_js_1.APP_PREFIX, err)); return this.queue; } /** * Returns the formatted stack including the stack trace, steps, and logs. * @returns {string} */ formatLogs({ error, steps, logs }) { error = error?.trim(); logs = logs?.trim().split('\n').map(l => (0, utils_js_1.truncate)(l)).join('\n'); if (Array.isArray(steps)) { steps = steps .map(step => (0, utils_js_1.formatStep)(step)) .flat() .join('\n'); } let testLogs = ''; if (steps) testLogs += `${picocolors_1.default.bold(picocolors_1.default.blue('################[ Steps ]################'))}\n${steps}\n\n`; if (logs) testLogs += `${picocolors_1.default.bold(picocolors_1.default.gray('################[ Logs ]################'))}\n${logs}\n\n`; if (error) testLogs += `${picocolors_1.default.bold(picocolors_1.default.red('################[ Failure ]################'))}\n${error}`; return testLogs; } formatError(error, message) { if (!message) message = error.message; if (error.inspect) message = error.inspect() || ''; let stack = ''; if (error.name) stack += `${picocolors_1.default.red(error.name)}`; if (error.operator) stack += ` (${picocolors_1.default.red(error.operator)})`; // add new line if something was added to stack if (stack) stack += ': '; stack += `${message}\n`; if (error.diff) { // diff for vitest stack += error.diff; stack += '\n\n'; } else if (error.actual && error.expected && error.actual !== error.expected) { // diffs for mocha, cypress, codeceptjs style stack += `\n\n${picocolors_1.default.bold(picocolors_1.default.green('+ expected'))} ${picocolors_1.default.bold(picocolors_1.default.red('- actual'))}`; stack += `\n${picocolors_1.default.green(`+ ${error.expected.toString().split('\n').join('\n+ ')}`)}`; stack += `\n${picocolors_1.default.red(`- ${error.actual.toString().split('\n').join('\n- ')}`)}`; stack += '\n\n'; } const customFilter = process.env.TESTOMATIO_STACK_IGNORE; try { let hasFrame = false; const record = (0, callsite_record_1.default)({ forError: error, isCallsiteFrame: frame => { if (customFilter && (0, minimatch_1.minimatch)(frame.fileName, customFilter)) return false; if (hasFrame) return false; if (isNotInternalFrame(frame)) hasFrame = true; return hasFrame; }, }); // @ts-ignore if (record && !record.filename.startsWith('http')) { stack += record.renderSync({ stackFilter: isNotInternalFrame }); } return stack; } catch (e) { console.log(e); } } } exports.Client = Client; function isNotInternalFrame(frame) { return (frame.getFileName() && frame.getFileName().includes(path_1.sep) && !frame.getFileName().includes('node_modules') && !frame.getFileName().includes('internal')); } /** * * @param {TestData} testData * @returns boolean */ function isTestShouldBeExculedFromReport(testData) { // const fileName = path.basename(test.location?.file || ''); const globExcludeFilesPattern = process.env.TESTOMATIO_EXCLUDE_FILES_FROM_REPORT_GLOB_PATTERN; if (!globExcludeFilesPattern) return false; if (!testData.file) { debug('No "file" property found for test ', testData.title); return false; } const excludeParretnsList = globExcludeFilesPattern.split(';'); // as scanning files is time consuming operation, just save the result in variable to avoid multiple scans if (!listOfTestFilesToExcludeFromReport) { // list of files with relative paths listOfTestFilesToExcludeFromReport = glob_1.glob.sync(excludeParretnsList, { ignore: '**/node_modules/**' }); debug('Tests from next files will not be reported:', listOfTestFilesToExcludeFromReport); } const testFileRelativePath = path_1.default.relative(process.cwd(), testData.file); // no files found matching the exclusion pattern if (!listOfTestFilesToExcludeFromReport.length) return false; if (listOfTestFilesToExcludeFromReport.includes(testFileRelativePath)) { debug(`Excluding test '${testData.title}' <${testFileRelativePath}> from reporting`); return true; } return false; } module.exports = Client; module.exports.Client = Client;