@testomatio/reporter
Version:
Testomatio Reporter Client
436 lines (435 loc) • 20.5 kB
JavaScript
;
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;