UNPKG

@testomatio/reporter

Version:
436 lines (435 loc) 20.5 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const js_yaml_1 = __importDefault(require("js-yaml")); const child_process_1 = require("child_process"); const gaxios_1 = require("gaxios"); const minimatch_1 = require("minimatch"); const constants_js_1 = require("../constants.js"); const pipe_utils_js_1 = require("../utils/pipe_utils.js"); const pipe_utils_js_2 = require("../utils/pipe_utils.js"); const config_js_1 = require("../config.js"); const debug_1 = __importDefault(require("debug")); const debug = (0, debug_1.default)('@testomatio/reporter:pipe:csv'); // Example of use 'coverage:file=coverage/coverage.yml,diff=master' cmd: // | Option | Git command | Notes | // | --- | --- | --- | // | --filter "coverage:file=coverage/coverage.yml,diff=new-branch" | ✅ git diff new-branch --name-only | - | // | --filter "coverage:diff=master,file=coverage.yml" | ✅ git diff master --name-only | - | // | --filter "coverage:file=coverage/coverage.yml" | ✅ git diff master --name-only | default branch = "master" | // | --filter "coverage:file=coverage.yml,dif=noexist-branch" | ❌ Git command failed ...| - | // | --filter "coverage:file=coverage.yml,diff=noexist-branch" | ❌ Git command failed ...| because no branch found | // | --filter "coverage:file=no-exist-coverage.yml" | ❌ Coverage file not found: <>filename>.yml | - | // | --filter "coverage:filepath=coverage.yml" | 🚫 Missing required parameter: "file"... | - | // | --filter "coverage:diff=my-branch" | 🚫 Missing required parameter: "file"...| - | //maybe GitChanges or GitCoverage class CoveragePipe { #GIT = { default_branch: 'master', diff_command: 'git diff', only_file_opt: '--name-only', uncommitted_marker: 'uncommitted', // test_defaultGitChangedFile - uses only for unit tests in "coverage_pipe_test.js" file test_defaultGitChangedFile: ['todomvc-tests/edit-todos_test.js'], }; constructor(params, store) { this.id = 'coverage'; // as future updates -> find by id in client.js this.store = store || {}; this.branch = undefined; this.isDefaultGitChanges = false; // COVERAGE_BY_DEFAULT_GIT_FILE env uses only for unit tests this.isEnabled = false; const { pipeOptions } = params; const options = (0, pipe_utils_js_2.parsePipeOptions)(pipeOptions || ""); debug("Pipe options", options); // this.isDefaultGitChanges - COVERAGE_BY_DEFAULT_GIT_FILE env uses only for unit tests this.isDefaultGitChanges = process.env.COVERAGE_BY_DEFAULT_GIT_FILE === '1' ? true : false; this.coverageFilePath = options?.file || process.env.COVERAGE_FILEPATH || undefined; if (!this.coverageFilePath) return; this.branch = options?.diff || process.env.COVERAGE_BRANCH || this.#GIT.default_branch; this.isBranchDefault = !options.diff && !process.env.COVERAGE_BRANCH; if (this.isBranchDefault) { console.log(constants_js_1.APP_PREFIX, `🟡 No "diff" branch provided. That's why we use default one = "${this.branch}".\n` + '👉 You can set it via --filter "coverage:file=coverage.yml,diff=your-branch"'); } // Client config section this.formattedDate = new Date().toISOString().replace(/T/, '-').replace(/:/g, '-').split('.')[0]; this.title = process.env.TESTOMATIO_TITLE || `Testomatio Coverage Test Execution - ${this.formattedDate}`; this.apiKey = process.env['INPUT_TESTOMATIO-KEY'] || config_js_1.config.TESTOMATIO; this.url = params.testomatioUrl || process.env.TESTOMATIO_URL || 'https://app.testomat.io'; const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY || null; const proxy = proxyUrl ? new URL(proxyUrl) : null; // 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 } } }); // In case if we have all needed data this.isEnabled = true; debug('Coverage Pipe initialized', { branch: this.branch, coverageFilePath: this.coverageFilePath, }); this.parsedCoverage = {}; this.changedFiles = []; this.matchedLines = new Set(); this.tests = new Set(); this.suiteIds = new Set(); this.tagLabels = new Set(); this.results = []; debug(`Coverage Pipe: is Enabled = ${this.isEnabled}`); } async prepareRun(opts) { // Reset internal mutable state for isolation this.tests.clear(); this.suiteIds.clear(); this.tagLabels.clear(); this.results = []; if (this.store) { this.store.coverageConfiguration = undefined; } if (!this.isEnabled) return []; // Step 1: Validate coverage file path & Git changes & Coverage parsing if (!this.getGitChangedFiles()?.validateCoverageFile()?.parseCoverageFile()) return []; // Step 2: Extract all available tests and compare with coverage file const lines = await this.extractRelevantTestsFromChanges(); if (this.store?.filterList && lines.size > 0) { console.log(constants_js_1.APP_PREFIX, `Matched files: ${[...lines].join(', ')}`); } if (lines.size === 0) { console.log(constants_js_1.APP_PREFIX, 'ℹ️ No matching entries in coverage file for provided Git changes.'); return []; } // Step 3: Handle tag labels tests from the server // if (this.tagLabels && this.tagLabels.size > 0) { //TODO: in case if we add labels in future!!! if (this.tagLabels.size > 0) { for (const tag of this.tagLabels) { const tagType = 'tag'; const tests = await this.#getTestomatioTestsByParam(tagType, tag); if (!tests) return []; console.log(constants_js_1.APP_PREFIX, `✅ We found ${tests.length === 1 ? 'one entry' : `${tests.length} (test/suite) entries`}` + ' in Testomat.io service side.'); tests.forEach(testId => this.tests.add(testId)); } } if (this.tests.size === 0 && this.suiteIds.size === 0) { console.log(constants_js_1.APP_PREFIX, 'ℹ️ No tests found for execution based on Git changes.'); return []; } this.results = [...this.tests, ...this.suiteIds]; if (this.store) { this.store.coverageConfiguration = { tests: [...this.tests], suites: [...this.suiteIds], }; this.store.coverageDescription = this.#buildRunDescription({ matchedLines: lines, testsCount: this.tests.size, suitesCount: this.suiteIds.size, }); } return this.results; } addTest(data) { } async createRun() { } updateRun() { } async finishRun(runParams) { } async sync() { // CoveragePipe doesn't buffer tests, so sync is a no-op // Reserved for future use if needed } toString() { return 'Coverage Reporter'; } /** * Fetches a list of tests from the Testomat.io server based on a given filter parameter. * * The method parses the provided filter (`type=id`), builds the appropriate request parameters, * sends a GET request to the Testomat.io `/api/test_grep` endpoint, and returns the matching tests. * * If the filter is invalid, no query is generated, or the server responds with no matching tests, * it logs relevant information and returns `undefined`. * * @async function * @param {string} type - The filter string in the format like `tag-name` for tag by. * @param {string} id - The filter string in the format like `smoke`. * @returns {Promise<Array<Object>|undefined>} Resolves to an array of test objects if found, otherwise `undefined`. */ async #getTestomatioTestsByParam(type, id) { // Get tests from the server 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) { console.log(constants_js_1.APP_PREFIX, `🔍 No test by ${type}=${id} were found on the Testomat.io server side!`); return undefined; } return resp.data.tests; } catch (err) { console.error(constants_js_1.APP_PREFIX, `🚩 Error getting available tests from the Testomat.io by "test_grep" option: ${err}`); return undefined; } } /** * Executes a Git command to retrieve a list of changed files. * * @param {string} cmd - The Git command to execute. * @returns {string[]} An array of changed file paths. Returns an empty array if an error occurs * (e.g., not a Git repository or command failure). */ #getChangedFilesFromGit(cmd) { try { const result = (0, child_process_1.execSync)(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }); return result .split('\n') .map(f => f.trim()) .filter(Boolean); } catch (err) { const errorMessage = err.message || ''; // Git edge: Not a git repository or other error if (errorMessage.includes('Not a git repository')) { console.error(constants_js_1.APP_PREFIX, '❌ Error: This folder is not a Git repository.'); } else { throw new Error(`❌ Git command failed ("${cmd}"):\n`, errorMessage); } return []; } } /** * Builds a Git command string to list file changes between the current state * and a specified Git branch using `git diff --name-only`. * * Private pipe function * @throws {Error} Throws an error if `this.branch` is not defined. * @returns {string} A Git command string, e.g., 'git diff <branch> --name-only'. */ #buildGitCommand() { if (!this.branch) throw new Error(`❌ Invalid changes option for setted branch!`); return `git diff ${this.branch} --name-only`; // Example: 'git diff <master> --name-only' } /** * Retrieves the list of files changed in the current Git working directory * compared to a specified branch. * * This method builds a Git diff command and attempts to retrieve the changed * files using that command. It logs helpful information and errors during the process. * * If no changed files are found, or an error occurs at any stage, the method logs * the issue and returns `undefined`. * * @returns {this | undefined} Returns the current instance (`this`) if changed files are found; * otherwise, returns `undefined`. */ getGitChangedFiles() { let cmd; try { cmd = this.#buildGitCommand(); } catch (err) { console.error(constants_js_1.APP_PREFIX, err.message); return undefined; } console.error(constants_js_1.APP_PREFIX, `ℹ️ We will use '${cmd}' Git command.`); try { // For clear unit testing process -> Like test_defaultGitChangedFile = todomvc-tests/edit-todos_test.js if (this.isDefaultGitChanges) { this.changedFiles = this.#GIT.test_defaultGitChangedFile; } else { this.changedFiles = this.#getChangedFilesFromGit(cmd); if (this.changedFiles.length === 0) { console.log(constants_js_1.APP_PREFIX, 'ℹ️ No files changed in the latest Git commit. Skipping coverage processing.'); return undefined; } } } catch (err) { console.error(constants_js_1.APP_PREFIX, err.message); console.error(constants_js_1.APP_PREFIX, "🔍 Pls, check this Git command manually to understand the original problem."); return undefined; } console.log(constants_js_1.APP_PREFIX, `📑 GIT changed files:\n - ${this.changedFiles.join('\n - ')}`); return this; } /** * Validates the coverage file path (stored in `this.coverageFilePath`). * * This method checks: * - That the file exists on disk. * - That it is a regular file (not a directory or special file). * - That it has a `.yml` extension to ensure it's a YAML file. * * Logs descriptive error messages for any failures. * * @returns {this | undefined} "true" in case if coverage file is valid and we can keep going; * otherwise, `undefined`. */ validateCoverageFile() { // Validate the presence of the coverage filepath if (!fs_1.default.existsSync(this.coverageFilePath)) { console.log(constants_js_1.APP_PREFIX, '❌ Coverage file not found:', this.coverageFilePath); return undefined; } // Ensure the given path is a file (not a directory or other type) const stat = fs_1.default.statSync(this.coverageFilePath); if (!stat.isFile()) { console.log(constants_js_1.APP_PREFIX, '❌ Provided coverage path is not a file:', this.coverageFilePath); return undefined; } // Validate the file extension to be ".yml" to ensure it's a YAML file if (path_1.default.extname(this.coverageFilePath) !== ".yml") { console.log(constants_js_1.APP_PREFIX, '❌ Coverage file must have a .yml extension:', this.coverageFilePath); return undefined; } debug('Coverage file validation is OK!'); return this; } /** * Parses the YAML coverage file (located at `this.coverageFilePath`) into a JavaScript object. * * - Reads the file content using UTF-8 encoding. * - Parses it as YAML using the `yaml` library. * - Stores the result in `this.parsedCoverage`. * - If parsing fails, logs an error and returns `undefined`. * * @returns {this | undefined} The current parsed coverage yml file change lines, or "undefined" if parsing fails. */ parseCoverageFile() { try { // Read the contents of the YAML file and attempt to parse the YAML into a JavaScript object const rawYml = fs_1.default.readFileSync(this.coverageFilePath, 'utf8'); this.parsedCoverage = js_yaml_1.default.load(rawYml) || {}; debug(`Coverage filepath = ${this.coverageFilePath})`); console.log(constants_js_1.APP_PREFIX, `✅ Coverage file parsed successfully: ${this.coverageFilePath}`); return this; } catch (err) { console.error(constants_js_1.APP_PREFIX, '❌ Failed to parse YAML:', err.message); return undefined; } } /** * Extracts relevant test identifiers from changed files based on coverage mapping. * * Iterates over changed files and matches them against patterns in the parsed coverage data. * For each match, it extracts test IDs or tags and stores them in corresponding sets: * - Test IDs (starting with '@T' or '@S') are stored in `this.tests` * - Tag labels (starting with 'tag:') are stored in `this.tagLabels` * - Matched file paths are stored in `this.matchedLines` * * @returns {Promise <Set<string>>} A set of file paths that matched coverage patterns (`this.matchedLines`). */ async extractRelevantTestsFromChanges() { for (const changedFile of this.changedFiles) { for (const [pattern, ids] of Object.entries(this.parsedCoverage)) { if ((0, minimatch_1.minimatch)(changedFile, pattern)) { this.matchedLines.add(changedFile); ids.forEach(id => { // Example: "@Tt74099t1" if (id.startsWith('@T')) { this.tests.add(id.slice(1)); } // Example: "@Sd74099c1" else if (id.startsWith('@S')) { this.suiteIds.add(id.slice(1)); } // Example: "tag:@TestSmoke" else if (id.startsWith('tag')) { this.tagLabels.add(id.split(':')[1].slice(1)); } }); } } } debug(`Matched lines: ${this.matchedLines}`); return this.matchedLines; } #buildRunDescription({ matchedLines, testsCount, suitesCount }) { const sourceBranch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_REF_NAME || this.#getCurrentGitBranch() || 'current branch'; const targetBranch = this.branch || 'target branch'; const coverageFile = this.coverageFilePath ? path_1.default.basename(this.coverageFilePath) : 'coverage.yml'; const updatedFiles = matchedLines && matchedLines.size > 0 ? [...matchedLines] : this.changedFiles; let description = `Changes to **${updatedFiles.length}** files in ${sourceBranch} to ${targetBranch}.\n\n`; if (suitesCount > 0 || testsCount > 0) { const affectedItems = []; if (suitesCount > 0) affectedItems.push(`**${suitesCount} suites**`); if (testsCount > 0) affectedItems.push(`**${testsCount} individual tests**`); description += `May affect ${affectedItems.join(' and ')} which are recommended to be tested for regression.\n\n`; // eslint-disable-line } description += 'Updated source files:\n'; if (updatedFiles.length) { description += updatedFiles.map(file => `* \`${file}\``).join('\n'); description += '\n\n'; } else { description += '* No matched files found\n\n'; } description += `Mapping source files to tests set via \`${coverageFile}\` file.`; return description; } #getCurrentGitBranch() { try { const branch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], }).trim(); return branch || undefined; } catch (err) { return undefined; } } } module.exports = CoveragePipe;