@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
226 lines • 10.6 kB
JavaScript
import path from 'path';
import { PlanKind, } from '@stryker-mutator/api/core';
import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
import { notEmpty, split } from '@stryker-mutator/util';
import { coreTokens } from '../di/index.js';
import { objectUtils } from '../utils/object-utils.js';
import { optionsPath } from '../utils/index.js';
import { toRelativeNormalizedFileName, } from './incremental-differ.js';
/**
* The factor by which hit count from dry run is multiplied to calculate the hit limit for a mutant.
* This is intentionally a high value to prevent false positives.
*
* For example, a property testing library might execute a failing scenario multiple times to determine the smallest possible counterexample.
* @see https://jsverify.github.io/#minimal-counterexample
*/
const HIT_LIMIT_FACTOR = 100;
/**
* Responsible for determining the tests to execute for each mutant, as well as other run option specific details
*
*/
export class MutantTestPlanner {
testCoverage;
incrementalDiffer;
reporter;
sandbox;
project;
timeOverheadMS;
options;
logger;
static inject = tokens(coreTokens.testCoverage, coreTokens.incrementalDiffer, coreTokens.reporter, coreTokens.sandbox, coreTokens.project, coreTokens.timeOverheadMS, commonTokens.options, commonTokens.logger);
timeSpentAllTests;
constructor(testCoverage, incrementalDiffer, reporter, sandbox, project, timeOverheadMS, options, logger) {
this.testCoverage = testCoverage;
this.incrementalDiffer = incrementalDiffer;
this.reporter = reporter;
this.sandbox = sandbox;
this.project = project;
this.timeOverheadMS = timeOverheadMS;
this.options = options;
this.logger = logger;
this.timeSpentAllTests = calculateTotalTime(this.testCoverage.testsById.values());
}
async makePlan(mutants) {
const mutantsDiff = await this.incrementalDiff(mutants);
const mutantPlans = mutantsDiff.map((mutant) => this.planMutant(mutant));
this.reporter.onMutationTestingPlanReady({ mutantPlans });
this.warnAboutSlow(mutantPlans);
return mutantPlans;
}
planMutant(mutant) {
const isStatic = this.testCoverage.hasStaticCoverage(mutant.id);
if (mutant.status) {
// If this mutant was already ignored, early result
return this.createMutantEarlyResultPlan(mutant, {
isStatic,
coveredBy: mutant.coveredBy,
killedBy: mutant.killedBy,
status: mutant.status,
statusReason: mutant.statusReason,
});
}
else if (this.testCoverage.hasCoverage) {
// If there was coverage information (coverageAnalysis not "off")
const tests = this.testCoverage.testsByMutantId.get(mutant.id) ?? [];
const coveredBy = toTestIds(tests);
if (!isStatic || (this.options.ignoreStatic && coveredBy.length)) {
// If not static, or it was "hybrid" (both static and perTest coverage) and ignoreStatic is on.
// Only run covered tests with mutant active during runtime
const netTime = calculateTotalTime(tests);
return this.createMutantRunPlan(mutant, {
netTime,
coveredBy,
isStatic,
testFilter: coveredBy,
});
}
else if (this.options.ignoreStatic) {
// Static (w/o perTest coverage) and ignoreStatic is on -> Ignore.
return this.createMutantEarlyResultPlan(mutant, {
status: 'Ignored',
statusReason: 'Static mutant (and "ignoreStatic" was enabled)',
isStatic,
coveredBy,
});
}
else {
// Static (or hybrid) and `ignoreStatic` is off -> run all tests
return this.createMutantRunPlan(mutant, {
netTime: this.timeSpentAllTests,
isStatic,
coveredBy,
});
}
}
else {
// No coverage information exists, all tests need to run
return this.createMutantRunPlan(mutant, {
netTime: this.timeSpentAllTests,
});
}
}
createMutantEarlyResultPlan(mutant, { isStatic, status, statusReason, coveredBy, killedBy, }) {
return {
plan: PlanKind.EarlyResult,
mutant: {
...mutant,
status,
static: isStatic,
statusReason,
coveredBy,
killedBy,
},
};
}
createMutantRunPlan(mutant, { netTime, testFilter, isStatic, coveredBy, }) {
const { disableBail, timeoutMS, timeoutFactor } = this.options;
const timeout = timeoutFactor * netTime + timeoutMS + this.timeOverheadMS;
const hitCount = this.testCoverage.hitsByMutantId.get(mutant.id);
const hitLimit = hitCount === undefined ? undefined : hitCount * HIT_LIMIT_FACTOR;
return {
plan: PlanKind.Run,
netTime,
mutant: {
...mutant,
coveredBy,
static: isStatic,
},
runOptions: {
// Copy over relevant mutant fields, we don't want to copy over "static" and "coveredBy", test runners should only care about the testFilter
activeMutant: {
id: mutant.id,
fileName: mutant.fileName,
location: mutant.location,
mutatorName: mutant.mutatorName,
replacement: mutant.replacement,
},
mutantActivation: testFilter ? 'runtime' : 'static',
timeout,
testFilter,
sandboxFileName: this.sandbox.sandboxFileFor(mutant.fileName),
hitLimit,
disableBail,
reloadEnvironment: !testFilter,
},
};
}
warnAboutSlow(mutantPlans) {
if (!this.options.ignoreStatic &&
objectUtils.isWarningEnabled('slow', this.options.warnings)) {
// Only warn when the estimated time to run all static mutants exceeds 40%
// ... and when the average performance impact of a static mutant is estimated to be twice that (or more) of a non-static mutant
const ABSOLUTE_CUT_OFF_PERUNAGE = 0.4;
const RELATIVE_CUT_OFF_FACTOR = 2;
const zeroIfNaN = (n) => (isNaN(n) ? 0 : n);
const totalNetTime = (runPlans) => runPlans.reduce((acc, { netTime }) => acc + netTime, 0);
const runPlans = mutantPlans.filter(isRunPlan);
const [staticRunPlans, runTimeRunPlans] = split(runPlans, ({ mutant }) => Boolean(mutant.static));
const estimatedTimeForStaticMutants = totalNetTime(staticRunPlans);
const estimatedTimeForRunTimeMutants = totalNetTime(runTimeRunPlans);
const estimatedTotalTime = estimatedTimeForRunTimeMutants + estimatedTimeForStaticMutants;
const avgTimeForAStaticMutant = zeroIfNaN(estimatedTimeForStaticMutants / staticRunPlans.length);
const avgTimeForARunTimeMutant = zeroIfNaN(estimatedTimeForRunTimeMutants / runTimeRunPlans.length);
const relativeTimeForStaticMutants = estimatedTimeForStaticMutants / estimatedTotalTime;
const absoluteCondition = relativeTimeForStaticMutants >= ABSOLUTE_CUT_OFF_PERUNAGE;
const relativeCondition = avgTimeForAStaticMutant >=
RELATIVE_CUT_OFF_FACTOR * avgTimeForARunTimeMutant;
if (relativeCondition && absoluteCondition) {
const percentage = (perunage) => Math.round(perunage * 100);
this.logger.warn(`Detected ${staticRunPlans.length} static mutants (${percentage(staticRunPlans.length / runPlans.length)}% of total) that are estimated to take ${percentage(relativeTimeForStaticMutants)}% of the time running the tests!\n You might want to enable "ignoreStatic" to ignore these static mutants for your next run. \n For more information about static mutants visit: https://stryker-mutator.io/docs/mutation-testing-elements/static-mutants\n (disable "${optionsPath('warnings', 'slow')}" to ignore this warning)`);
}
}
}
async incrementalDiff(currentMutants) {
const { incrementalReport } = this.project;
if (incrementalReport) {
const currentFiles = await this.readAllOriginalFiles(currentMutants, this.testCoverage.testsById.values(), Object.keys(incrementalReport.files), Object.keys(incrementalReport.testFiles ?? {}));
const diffedMutants = this.incrementalDiffer.diff(currentMutants, this.testCoverage, incrementalReport, currentFiles);
return diffedMutants;
}
return currentMutants;
}
async readAllOriginalFiles(...thingsWithFileNamesOrFileNames) {
const uniqueFileNames = [
...new Set(thingsWithFileNamesOrFileNames
.flatMap((container) => [...container].map((thing) => typeof thing === 'string' ? thing : thing.fileName))
.filter(notEmpty)
.map((fileName) => path.resolve(fileName))),
];
const result = await Promise.all(uniqueFileNames.map(async (fileName) => {
const originalContent = await this.project.files
.get(fileName)
?.readOriginal();
if (originalContent) {
return [
toRelativeNormalizedFileName(fileName),
originalContent,
];
}
else {
return undefined;
}
}));
return new Map(result.filter(notEmpty));
}
}
function calculateTotalTime(testResults) {
let total = 0;
for (const test of testResults) {
total += test.timeSpentMs;
}
return total;
}
function toTestIds(testResults) {
const result = [];
for (const test of testResults) {
result.push(test.id);
}
return result;
}
export function isEarlyResult(mutantPlan) {
return mutantPlan.plan === PlanKind.EarlyResult;
}
export function isRunPlan(mutantPlan) {
return mutantPlan.plan === PlanKind.Run;
}
//# sourceMappingURL=mutant-test-planner.js.map