mirror of https://github.com/renovatebot/renovate
459 lines
12 KiB
TypeScript
459 lines
12 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import os from 'node:os';
|
|
import { env } from 'node:process';
|
|
import v8 from 'node:v8';
|
|
import { minimatch } from 'minimatch';
|
|
import type { JestConfigWithTsJest } from 'ts-jest';
|
|
|
|
const ci = !!process.env.CI;
|
|
|
|
type JestConfig = JestConfigWithTsJest & {
|
|
// https://github.com/renovatebot/renovate/issues/17034
|
|
workerIdleMemoryLimit?: string;
|
|
};
|
|
|
|
const cpus = os.cpus();
|
|
const mem = os.totalmem();
|
|
const stats = v8.getHeapStatistics();
|
|
|
|
/**
|
|
* https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
|
|
* Currently it seems the runner only have 4GB
|
|
*/
|
|
function jestGithubRunnerSpecs(): JestConfig {
|
|
// if (os.platform() === 'darwin') {
|
|
// return {
|
|
// maxWorkers: 2,
|
|
// workerIdleMemoryLimit: '4GB',
|
|
// };
|
|
// }
|
|
|
|
return {
|
|
maxWorkers: cpus.length,
|
|
workerIdleMemoryLimit: '1500MB', // '2GB',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Configuration for single test shard.
|
|
*/
|
|
interface ShardConfig {
|
|
/**
|
|
* Path patterns to match against the test file paths, of two types:
|
|
*
|
|
* 1. Particular file, e.g. `lib/util/git/index.spec.ts`
|
|
*
|
|
* - File pattern MUST end with `.spec.ts`
|
|
* - This will only search for the particular test file
|
|
* - It enables coverage for the `*.ts` file with the same name,
|
|
* e.g. `lib/util/git/index.ts`
|
|
* - You probably want to use directory pattern instead
|
|
*
|
|
* 2. Whole directory, e.g. `lib/modules/datasource`
|
|
*
|
|
* - This will search for all `*.spec.ts` files under the directory
|
|
* - It enables coverage all `*.ts` files under the directory,
|
|
* e.g. `lib/modules/datasource/foo/bar/baz.ts`
|
|
*/
|
|
matchPaths: string[];
|
|
}
|
|
|
|
/**
|
|
* Configuration for test shards that can be run with `TEST_SHARD` environment variable.
|
|
*
|
|
* For each shard, we specify a subset of tests to run.
|
|
* The tests from previous shards are excluded from the next shard.
|
|
*
|
|
* Storing shards config in the separate file helps to form CI matrix
|
|
* using pre-installed `jq` utility.
|
|
*/
|
|
const testShards: Record<string, ShardConfig> = {
|
|
'datasources-1': {
|
|
matchPaths: ['lib/modules/datasource/[a-g]*'],
|
|
},
|
|
'datasources-2': {
|
|
matchPaths: ['lib/modules/datasource'],
|
|
},
|
|
'managers-1': {
|
|
matchPaths: ['lib/modules/manager/[a-c]*'],
|
|
},
|
|
'managers-2': {
|
|
matchPaths: ['lib/modules/manager/[d-h]*'],
|
|
},
|
|
'managers-3': {
|
|
matchPaths: ['lib/modules/manager/[i-n]*'],
|
|
},
|
|
'managers-4': {
|
|
matchPaths: ['lib/modules/manager'],
|
|
},
|
|
platform: {
|
|
matchPaths: ['lib/modules/platform'],
|
|
},
|
|
versioning: {
|
|
matchPaths: ['lib/modules/versioning'],
|
|
},
|
|
'workers-1': {
|
|
matchPaths: [
|
|
'lib/workers/repository/changelog',
|
|
'lib/workers/repository/config-migration',
|
|
'lib/workers/repository/extract',
|
|
'lib/workers/repository/finalize',
|
|
'lib/workers/repository/init',
|
|
'lib/workers/repository/model',
|
|
],
|
|
},
|
|
'workers-2': {
|
|
matchPaths: [
|
|
'lib/workers/repository/onboarding',
|
|
'lib/workers/repository/process',
|
|
],
|
|
},
|
|
'workers-3': {
|
|
matchPaths: [
|
|
'lib/workers/repository/update',
|
|
'lib/workers/repository/updates',
|
|
],
|
|
},
|
|
'workers-4': {
|
|
matchPaths: ['lib/workers'],
|
|
},
|
|
'git-1': {
|
|
matchPaths: ['lib/util/git/index.spec.ts'],
|
|
},
|
|
'git-2': {
|
|
matchPaths: ['lib/util/git'],
|
|
},
|
|
util: {
|
|
matchPaths: ['lib/util'],
|
|
},
|
|
other: {
|
|
matchPaths: ['lib'],
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Subset of Jest config that is relevant for sharded test run.
|
|
*/
|
|
type JestShardedSubconfig = Pick<JestConfig, 'testMatch' | 'coverageDirectory'>;
|
|
|
|
/**
|
|
* Convert match pattern to a form that matches on file with `.ts` or `.spec.ts` extension.
|
|
*/
|
|
function normalizePattern(pattern: string, suffix: '.ts' | '.spec.ts'): string {
|
|
return pattern.endsWith('.spec.ts')
|
|
? pattern.replace(/\.spec\.ts$/, suffix)
|
|
: `${pattern}/**/*${suffix}`;
|
|
}
|
|
|
|
/**
|
|
* Generates Jest config for sharded test run.
|
|
*
|
|
* If `TEST_SHARD` environment variable is not set,
|
|
* it falls back to the provided config.
|
|
*
|
|
* Otherwise, `fallback` value is used to determine some defaults.
|
|
*/
|
|
function configureShardingOrFallbackTo(
|
|
fallback: JestShardedSubconfig,
|
|
): JestShardedSubconfig {
|
|
const shardKey = process.env.TEST_SHARD;
|
|
if (!shardKey) {
|
|
return fallback;
|
|
}
|
|
|
|
if (!testShards[shardKey]) {
|
|
const keys = Object.keys(testShards).join(', ');
|
|
throw new Error(
|
|
`Unknown value for TEST_SHARD: ${shardKey} (possible values: ${keys})`,
|
|
);
|
|
}
|
|
|
|
const testMatch: string[] = [];
|
|
|
|
for (const [key, { matchPaths: patterns }] of Object.entries(testShards)) {
|
|
if (key === shardKey) {
|
|
const testMatchPatterns = patterns.map((pattern) => {
|
|
const filePattern = normalizePattern(pattern, '.spec.ts');
|
|
return `<rootDir>/${filePattern}`;
|
|
});
|
|
testMatch.push(...testMatchPatterns);
|
|
break;
|
|
}
|
|
|
|
const testMatchPatterns = patterns.map((pattern) => {
|
|
const filePattern = normalizePattern(pattern, '.spec.ts');
|
|
return `!**/${filePattern}`;
|
|
});
|
|
testMatch.push(...testMatchPatterns);
|
|
}
|
|
|
|
testMatch.reverse();
|
|
|
|
const coverageDirectory = `./coverage/shard/${shardKey}`;
|
|
return {
|
|
testMatch,
|
|
coverageDirectory,
|
|
};
|
|
}
|
|
|
|
const config: JestConfig = {
|
|
...configureShardingOrFallbackTo({
|
|
coverageDirectory: './coverage',
|
|
}),
|
|
collectCoverageFrom: [
|
|
'lib/**/*.{js,ts}',
|
|
'!lib/**/*.{d,spec}.ts',
|
|
'!lib/**/{__fixtures__,__mocks__,__testutil__,test}/**/*.{js,ts}',
|
|
'!lib/**/types.ts',
|
|
],
|
|
coveragePathIgnorePatterns: getCoverageIgnorePatterns(),
|
|
cacheDirectory: '.cache/jest',
|
|
collectCoverage: true,
|
|
coverageReporters: ci
|
|
? ['lcovonly', 'json']
|
|
: ['html', 'text-summary', 'json'],
|
|
transform: {
|
|
'\\.ts$': [
|
|
'ts-jest',
|
|
{
|
|
tsconfig: '<rootDir>/tsconfig.spec.json',
|
|
diagnostics: false,
|
|
isolatedModules: true,
|
|
},
|
|
],
|
|
},
|
|
modulePathIgnorePatterns: [
|
|
'<rootDir>/dist/',
|
|
'/__fixtures__/',
|
|
'/__mocks__/',
|
|
],
|
|
reporters: ci ? ['default', 'github-actions'] : ['default'],
|
|
resetMocks: true,
|
|
setupFilesAfterEnv: [
|
|
'jest-extended/all',
|
|
'expect-more-jest',
|
|
'<rootDir>/test/setup.ts',
|
|
'<rootDir>/test/to-migrate.ts',
|
|
],
|
|
snapshotSerializers: ['<rootDir>/test/newline-snapshot-serializer.ts'],
|
|
testEnvironment: 'node',
|
|
testRunner: 'jest-circus/runner',
|
|
watchPathIgnorePatterns: ['<rootDir>/.cache/', '<rootDir>/coverage/'],
|
|
// We can play with that value later for best dev experience
|
|
workerIdleMemoryLimit: '500MB',
|
|
// add github runner specific limits
|
|
...(ci && jestGithubRunnerSpecs()),
|
|
};
|
|
|
|
export default config;
|
|
|
|
type RunsOn = 'ubuntu-latest' | 'windows-latest' | 'macos-latest';
|
|
|
|
interface ShardGroup {
|
|
/**
|
|
* Input for `runs-on` field.
|
|
*/
|
|
os: RunsOn;
|
|
|
|
/**
|
|
* Controls whether coverage is collected for this shard group.
|
|
*/
|
|
coverage: boolean;
|
|
|
|
/**
|
|
* Input for `name` field.
|
|
*/
|
|
name: string;
|
|
|
|
/**
|
|
* Space-separated list of shard keys, it's
|
|
* meant to be inserted into bash for-loop.
|
|
*/
|
|
shards: string;
|
|
|
|
/**
|
|
* It's meant to be used for Jest caching.
|
|
*/
|
|
'cache-key': string;
|
|
|
|
/**
|
|
* It's used to set test runner timeout.
|
|
*/
|
|
'runner-timeout-minutes': number;
|
|
|
|
/**
|
|
* It's used to set `--test-timeout` Jest CLI flag.
|
|
*/
|
|
'test-timeout-milliseconds': number;
|
|
|
|
/**
|
|
* It's used as the name for coverage artifact.
|
|
*/
|
|
'upload-artifact-name': string;
|
|
}
|
|
|
|
/**
|
|
* Given the file list affected by commit, return the list
|
|
* of shards that test these changes.
|
|
*/
|
|
function getMatchingShards(files: string[]): string[] {
|
|
const matchingShards = new Set<string>();
|
|
for (const file of files) {
|
|
for (const [key, { matchPaths }] of Object.entries(testShards)) {
|
|
const patterns = matchPaths.map((path) =>
|
|
path.endsWith('.spec.ts')
|
|
? path.replace(/\.spec\.ts$/, '{.ts,.spec.ts}')
|
|
: `${path}/**/*`,
|
|
);
|
|
|
|
if (patterns.some((pattern) => minimatch(file, pattern))) {
|
|
matchingShards.add(key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return Object.keys(testShards).filter((shard) => matchingShards.has(shard));
|
|
}
|
|
|
|
/**
|
|
* Distribute items evenly across runner instances.
|
|
*/
|
|
function scheduleItems<T>(items: T[], availableInstances: number): T[][] {
|
|
const numInstances = Math.min(items.length, availableInstances);
|
|
const maxPerInstance = Math.ceil(items.length / numInstances);
|
|
const lighterInstancesIdx =
|
|
items.length % numInstances === 0
|
|
? numInstances
|
|
: items.length % numInstances;
|
|
|
|
const partitionSizes = Array.from({ length: numInstances }, (_, idx) =>
|
|
idx < lighterInstancesIdx ? maxPerInstance : maxPerInstance - 1,
|
|
);
|
|
|
|
const result: T[][] = Array.from({ length: numInstances }, () => []);
|
|
let rest = items.slice();
|
|
for (let idx = 0; idx < numInstances; idx += 1) {
|
|
const partitionSize = partitionSizes[idx];
|
|
const partition = rest.slice(0, partitionSize);
|
|
result[idx] = partition;
|
|
rest = rest.slice(partitionSize);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* If `SCHEDULE_TEST_SHARDS` env variable is set, it means we're in `setup` CI job.
|
|
* We don't want to see anything except key-value pairs in the output.
|
|
* Otherwise, we're printing useful stats.
|
|
*/
|
|
if (process.env.SCHEDULE_TEST_SHARDS) {
|
|
let shardKeys = Object.keys(testShards);
|
|
|
|
if (process.env.FILTER_SHARDS === 'true' && process.env.CHANGED_FILES) {
|
|
try {
|
|
const changedFiles: string[] = JSON.parse(process.env.CHANGED_FILES);
|
|
const matchingShards = getMatchingShards(changedFiles);
|
|
if (matchingShards.length === 0) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`test-matrix-empty=true`);
|
|
process.exit(0);
|
|
}
|
|
shardKeys = shardKeys.filter((key) => matchingShards.includes(key));
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Not all runners are created equal.
|
|
* Minutes cost proportion is 1:2:10 for Ubuntu:Windows:MacOS.
|
|
*
|
|
* Although it's free in our case,
|
|
* we can't run as many Windows and MacOS runners as we want.
|
|
*
|
|
* Because of this, we partition shards into groups, given that:
|
|
* - There are 16 shards in total
|
|
* - We can't run more than 10 Windows runners
|
|
* - We can't run more than 5 MacOS runners
|
|
*/
|
|
const shardGrouping: Record<string, string[][]> = {
|
|
'ubuntu-latest': scheduleItems(shardKeys, 16),
|
|
};
|
|
|
|
if (process.env.ALL_PLATFORMS === 'true') {
|
|
// shardGrouping['windows-latest'] = scheduleItems(shardKeys, 8);
|
|
shardGrouping['macos-latest'] = scheduleItems(shardKeys, 4);
|
|
}
|
|
|
|
const shardGroups: ShardGroup[] = [];
|
|
for (const [os, groups] of Object.entries(shardGrouping)) {
|
|
const coverage = os === 'ubuntu-latest';
|
|
|
|
const total = groups.length;
|
|
for (let idx = 0; idx < groups.length; idx += 1) {
|
|
const number = idx + 1;
|
|
const platform = os.replace(/-latest$/, '');
|
|
const name =
|
|
platform === 'ubuntu'
|
|
? `test (${number}/${total})`
|
|
: `test-${platform} (${number}/${total})`;
|
|
|
|
const shards = groups[idx];
|
|
const cacheKey = crypto
|
|
.createHash('md5')
|
|
.update(shards.join(':'))
|
|
.digest('hex');
|
|
|
|
const runnerTimeoutMinutes =
|
|
{
|
|
ubuntu: 10,
|
|
windows: 20,
|
|
macos: 20,
|
|
}[platform] ?? 20;
|
|
|
|
const testTimeoutMilliseconds =
|
|
{
|
|
windows: 240000,
|
|
}[platform] ?? 120000;
|
|
|
|
shardGroups.push({
|
|
os: os as RunsOn,
|
|
coverage,
|
|
name,
|
|
shards: shards.join(' '),
|
|
'cache-key': cacheKey,
|
|
'runner-timeout-minutes': runnerTimeoutMinutes,
|
|
'test-timeout-milliseconds': testTimeoutMilliseconds,
|
|
'upload-artifact-name': `coverage-${shards.sort().join('_')}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Output will be consumed by `setup` CI job.
|
|
*/
|
|
// eslint-disable-next-line no-console
|
|
console.log(`test-shard-matrix=${JSON.stringify(shardGroups)}`);
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
process.stderr.write(`Host stats:
|
|
Cpus: ${cpus.length}
|
|
Memory: ${(mem / 1024 / 1024 / 1024).toFixed(2)} GB
|
|
HeapLimit: ${(stats.heap_size_limit / 1024 / 1024 / 1024).toFixed(2)} GB
|
|
`);
|
|
function getCoverageIgnorePatterns(): string[] | undefined {
|
|
const patterns = ['/node_modules/', '<rootDir>/test/', '<rootDir>/tools/'];
|
|
|
|
if (env.TEST_LEGACY_DECRYPTION !== 'true') {
|
|
patterns.push('<rootDir>/lib/config/decrypt/legacy.ts');
|
|
}
|
|
|
|
return patterns;
|
|
}
|