@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
202 lines • 10.3 kB
JavaScript
import path from 'path';
import { PlanKind, MutantStatus } 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 {
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) {
var _a;
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 = (_a = this.testCoverage.testsByMutantId.get(mutant.id)) !== null && _a !== void 0 ? _a : [];
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: MutantStatus.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) {
var _a;
const { incrementalReport } = this.project;
if (incrementalReport) {
const currentFiles = await this.readAllOriginalFiles(currentMutants, this.testCoverage.testsById.values(), Object.keys(incrementalReport.files), Object.keys((_a = incrementalReport.testFiles) !== null && _a !== void 0 ? _a : {}));
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) => {
var _a;
const originalContent = await ((_a = this.project.files.get(fileName)) === null || _a === void 0 ? void 0 : _a.readOriginal());
if (originalContent) {
return [toRelativeNormalizedFileName(fileName), originalContent];
}
else {
return undefined;
}
}));
return new Map(result.filter(notEmpty));
}
}
MutantTestPlanner.inject = tokens(coreTokens.testCoverage, coreTokens.incrementalDiffer, coreTokens.reporter, coreTokens.sandbox, coreTokens.project, coreTokens.timeOverheadMS, commonTokens.options, commonTokens.logger);
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