Files
gitlabhq/scripts/frontend/find_jest_predictive_tests.js
2025-07-10 18:07:41 +00:00

176 lines
5.2 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Unlike rspec tests, we don't have a crystalball mapping file to calculate predicted tests, instead we need to find them using the dependency graph generated at runtime.
*
* So, we use helper functions in this module to list the frontend predictive tests (Jest) that are ran in tier-1, based on these criteria
* 1. Changed files (changed_files.txt)
* 2. Vue version predictive tests -- this doesn't affect test discovery, only test execution, so, we'lll get the list using Vue2
* 2b. Vue 2 (includes all tests)
* 2a. Vue 3 (some vue3 tests are quarantined)
* 3. Backend changes (`js_matching_files.txt`)
* 3a. Fixtures
* 3b. Views
* 4. Jest integration tests (uses --config `jest.config.integration.js`)
*
* CI rule match these file patterns
* .frontend-predictive-patterns:
* '{,ee/,jh/}{app/assets/javascripts,spec/frontend}/**'
*
* Command usage:
* Run
* ```sh
* # set env variables and then run the following
* ./scripts/frontend/find_jest_predictive_tests.js
* ```
*/
const { spawnSync } = require('node:child_process');
const { readFileSync, writeFileSync, mkdirSync } = require('node:fs');
const { relative, dirname } = require('node:path');
const requiredEnv = [
'JEST_MATCHING_TEST_FILES_PATH',
'RSPEC_MATCHING_JS_FILES_PATH',
'RSPEC_CHANGED_FILES_PATH',
];
function hasRequiredEnvironmentVariables() {
const missing = requiredEnv.filter((envVar) => !process.env[envVar]);
if (missing.length > 0) {
console.warn(`Warning: Missing required environment variables: ${missing.join(', ')}`);
console.warn('Some functionality may not work as expected.');
process.exitCode = 1;
}
}
function getChangedFiles() {
const files = [];
const { RSPEC_MATCHING_JS_FILES_PATH, RSPEC_CHANGED_FILES_PATH } = process.env;
for (const [name, filePath] of Object.entries({
RSPEC_CHANGED_FILES_PATH,
RSPEC_MATCHING_JS_FILES_PATH,
})) {
try {
const contents = readFileSync(filePath, { encoding: 'UTF-8' });
files.push(...contents.split(/\s+/).filter(Boolean));
} catch (error) {
console.warn(
`Failed to read from path ${filePath} given by environment variable ${name}`,
error,
);
}
}
return Array.from(new Set(files));
}
function findJestTests(config, changedFiles) {
const args = ['--ci', '--config', config, '--listTests'];
if (changedFiles) {
if (!changedFiles.length) return [];
args.push('--findRelatedTests');
args.push(...changedFiles);
}
try {
const childProcess = spawnSync('node_modules/.bin/jest', args, {
encoding: 'utf8',
stdio: 'pipe',
env: process.env,
});
if (childProcess.error) {
throw new Error(`Failed to spawn Jest: ${childProcess.error.message}`);
}
if (childProcess.status !== 0) {
const errorOutput = childProcess.stderr || 'No error output captured';
throw new Error(`Jest exited with code ${childProcess.status}: ${errorOutput}`);
}
if (!childProcess.stdout) {
console.warn('No output from Jest.');
return [];
}
return childProcess.stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.endsWith('.js'))
.map((test) => relative(process.cwd(), test));
} catch (error) {
throw new Error(`Failed to run Jest with config ${config}: ${error.message}`);
}
}
function collectTests(changedFiles) {
if (changedFiles && !changedFiles.length) {
console.log('No changed files found - no tests to run');
return [];
}
console.log(`Analyzing ${changedFiles.length} changed files...`);
const configs = [
{ name: 'unit', configPath: 'jest.config.js' },
{ name: 'integration', configPath: 'jest.config.integration.js' },
];
const allTests = [];
for (const { name, configPath } of configs) {
try {
console.log(`Checking ${name} tests...`);
const tests = findJestTests(configPath, changedFiles);
allTests.push(...tests);
} catch (error) {
console.error(`Error with ${name} tests config:\n`, error);
}
}
return Array.from(new Set(allTests)).sort();
}
function saveMatchingTestFiles(testFiles) {
const { JEST_MATCHING_TEST_FILES_PATH } = process.env;
if (JEST_MATCHING_TEST_FILES_PATH) {
mkdirSync(dirname(JEST_MATCHING_TEST_FILES_PATH), { recursive: true });
writeFileSync(JEST_MATCHING_TEST_FILES_PATH, testFiles.join(' '));
console.log(`Saved to: ${JEST_MATCHING_TEST_FILES_PATH}`);
}
}
function logAndSaveMatchingTestFiles(changedFiles) {
const matchingTestFiles = collectTests(changedFiles);
console.log(`\nFound ${matchingTestFiles.length} predictive test files`);
console.log('\n=== TEST FILES ===');
console.log(matchingTestFiles.join('\n'));
saveMatchingTestFiles(matchingTestFiles);
}
function listPredictiveTests() {
try {
hasRequiredEnvironmentVariables();
logAndSaveMatchingTestFiles(getChangedFiles());
} catch (error) {
console.error('Error finding predictive jest tests:', error);
process.exitCode = 1;
}
}
if (require.main === module) {
listPredictiveTests();
} else {
module.exports = {
hasRequiredEnvironmentVariables,
getChangedFiles,
findJestTests,
collectTests,
logAndSaveMatchingTestFiles,
};
}