@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
544 lines • 24.8 kB
JavaScript
import path from 'path';
import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
import chalk from 'chalk';
import { TestStatus } from '@stryker-mutator/api/test-runner';
import { normalizeFileName, normalizeLineEndings, notEmpty, } from '@stryker-mutator/util';
import { commonTokens } from '@stryker-mutator/api/plugin';
import { DiffStatisticsCollector, } from './diff-statistics-collector.js';
/**
* The 'diff match patch' high-performant 'diffing' of files.
* @see https://github.com/google/diff-match-patch
*/
const diffMatchPatch = new DiffMatchPatch();
/**
* This class is responsible for calculating the diff between a run and a previous run based on the incremental report.
*
* Since the ids of tests and mutants can differ across reports (they are only unique within 1 report), this class
* identifies mutants and tests by attributes that make them unique:
* - Mutant: file name, mutator name, location and replacement
* - Test: test name, test file name (if present) and location (if present).
*
* We're storing these identifiers in local variables (maps and sets) as strings.
* We should move to 'records' for these when they come available: https://github.com/tc39/proposal-record-tuple
*
* A mutant result from the previous run is reused if the following conditions were met:
* - The location of the mutant refers to a piece of code that didn't change
* - If mutant was killed:
* - The culprit test wasn't changed
* - If the mutant survived:
* - No test was added
*
* It uses google's "diff-match-patch" project to calculate the new locations for tests and mutants, see link.
* @link https://github.com/google/diff-match-patch
*/
export class IncrementalDiffer {
logger;
options;
mutantStatisticsCollector;
testStatisticsCollector;
mutateDescriptionByRelativeFileName;
static inject = [
commonTokens.logger,
commonTokens.options,
commonTokens.fileDescriptions,
];
constructor(logger, options, fileDescriptions) {
this.logger = logger;
this.options = options;
this.mutateDescriptionByRelativeFileName = new Map(Object.entries(fileDescriptions).map(([name, description]) => [
toRelativeNormalizedFileName(name),
description.mutate,
]));
}
isInMutatedScope(relativeFileName, mutant) {
const mutate = this.mutateDescriptionByRelativeFileName.get(relativeFileName);
return (mutate === true ||
(Array.isArray(mutate) &&
mutate.some((range) => locationIncluded(range, mutant.location))));
}
diff(currentMutants, testCoverage, incrementalReport, currentRelativeFiles) {
const { files, testFiles } = incrementalReport;
const mutantStatisticsCollector = new DiffStatisticsCollector();
const testStatisticsCollector = new DiffStatisticsCollector();
// Expose the collectors for unit testing purposes
this.mutantStatisticsCollector = mutantStatisticsCollector;
this.testStatisticsCollector = testStatisticsCollector;
// Collect what we can reuse, while correcting for diff in the locations
const reusableMutantsByKey = collectReusableMutantsByKey(this.logger);
const { byId: oldTestsById, byKey: oldTestInfoByKey } = collectReusableTestInfo(this.logger);
// Collect some helper maps and sets
const { oldCoverageByMutantKey: oldCoverageTestKeysByMutantKey, oldKilledByMutantKey: oldKilledTestKeysByMutantKey, } = collectOldKilledAndCoverageMatrix();
const oldTestKeys = new Set([...oldTestsById.values()].map(({ key }) => key));
const newTestKeys = new Set([...testCoverage.testsById].map(([, test]) => testToIdentifyingKey(test, toRelativeNormalizedFileName(test.fileName))));
// Create a dictionary to more easily get test information
const testInfoByKey = collectCurrentTestInfo();
// Mark which tests are added
for (const [key, { relativeFileName }] of testInfoByKey) {
if (!oldTestKeys.has(key)) {
testStatisticsCollector.count(relativeFileName, 'added');
}
}
// Make sure that tests that didn't run this time around aren't forgotten
for (const [testKey, { test: { name, location }, relativeFileName, },] of oldTestInfoByKey) {
if (!testInfoByKey.has(testKey)) {
const test = {
status: TestStatus.Success,
id: testKey,
name,
startPosition: location?.start,
timeSpentMs: 0,
fileName: path.resolve(relativeFileName),
};
testInfoByKey.set(testKey, {
test,
relativeFileName: relativeFileName,
});
testCoverage.addTest(test);
}
}
// Done with preparations, time to map over the mutants
let reusedMutantCount = 0;
const currentMutantKeys = new Set();
const mutants = currentMutants.map((mutant) => {
const relativeFileName = toRelativeNormalizedFileName(mutant.fileName);
const mutantKey = mutantToIdentifyingKey(mutant, relativeFileName);
currentMutantKeys.add(mutantKey);
if (!mutant.status && !this.options.force) {
const oldMutant = reusableMutantsByKey.get(mutantKey);
if (oldMutant) {
const coveringTests = testCoverage.forMutant(mutant.id);
const killedByTestKeys = oldKilledTestKeysByMutantKey.get(mutantKey);
if (mutantCanBeReused(mutant, oldMutant, mutantKey, coveringTests, killedByTestKeys)) {
reusedMutantCount++;
const { status, statusReason, testsCompleted } = oldMutant;
return {
...mutant,
status,
statusReason,
testsCompleted,
coveredBy: [...(coveringTests ?? [])].map(({ id }) => id),
killedBy: testKeysToId(killedByTestKeys),
};
}
}
else {
mutantStatisticsCollector.count(relativeFileName, 'added');
}
}
return mutant;
});
// Make sure that old mutants that didn't run this time around aren't forgotten
for (const [mutantKey, oldResult] of reusableMutantsByKey) {
// Do an additional check to see if the mutant is in mutated range.
//
// For example:
// ```diff
// - return a || b;
// + return a && b;
// ```
// The conditional expression mutator here decides to _not_ mutate b to `false` the second time around. (even though the text of "b" itself didn't change)
// Not doing this additional check would result in a sticky mutant that is never removed
if (!currentMutantKeys.has(mutantKey) &&
!this.isInMutatedScope(oldResult.relativeFileName, oldResult)) {
const coverage = oldCoverageTestKeysByMutantKey.get(mutantKey) ?? [];
const killed = oldKilledTestKeysByMutantKey.get(mutantKey) ?? [];
const coveredBy = testKeysToId(coverage);
const killedBy = testKeysToId(killed);
const reusedMutant = {
...oldResult,
id: mutantKey,
fileName: path.resolve(oldResult.relativeFileName),
replacement: oldResult.replacement ?? oldResult.mutatorName,
coveredBy,
killedBy,
};
mutants.push(reusedMutant);
testCoverage.addCoverage(reusedMutant.id, coveredBy);
}
}
if (this.logger.isInfoEnabled()) {
const testInfo = testCoverage.hasCoverage
? `\n\tTests:\t\t${testStatisticsCollector.createTotalsReport()}`
: '';
this.logger.info(`Incremental report:\n\tMutants:\t${mutantStatisticsCollector.createTotalsReport()}` +
testInfo +
`\n\tResult:\t\t${chalk.yellowBright(reusedMutantCount)} of ${currentMutants.length} mutant result(s) are reused.`);
}
if (this.logger.isDebugEnabled()) {
const lineSeparator = '\n\t\t';
const noChanges = 'No changes';
const detailedMutantSummary = `${lineSeparator}${mutantStatisticsCollector.createDetailedReport().join(lineSeparator) || noChanges}`;
const detailedTestsSummary = `${lineSeparator}${testStatisticsCollector.createDetailedReport().join(lineSeparator) || noChanges}`;
this.logger.debug(`Detailed incremental report:\n\tMutants: ${detailedMutantSummary}\n\tTests: ${detailedTestsSummary}`);
}
return mutants;
function testKeysToId(testKeys) {
return [...(testKeys ?? [])]
.map((id) => testInfoByKey.get(id))
.filter(notEmpty)
.map(({ test: { id } }) => id);
}
function collectReusableMutantsByKey(log) {
return new Map(Object.entries(files).flatMap(([fileName, oldFile]) => {
const relativeFileName = toRelativeNormalizedFileName(fileName);
const currentFileSource = currentRelativeFiles.get(relativeFileName);
if (currentFileSource) {
log.trace('Diffing %s', relativeFileName);
const { results, removeCount } = performFileDiff(oldFile.source, currentFileSource, oldFile.mutants);
mutantStatisticsCollector.count(relativeFileName, 'removed', removeCount);
return results.map((m) => [
mutantToIdentifyingKey(m, relativeFileName),
{
...m,
relativeFileName,
},
]);
}
mutantStatisticsCollector.count(relativeFileName, 'removed', oldFile.mutants.length);
// File has since been deleted, these mutants are not reused
return [];
}));
}
function collectReusableTestInfo(log) {
const byId = new Map();
const byKey = new Map();
Object.entries(testFiles ?? {}).forEach(([fileName, oldTestFile]) => {
const relativeFileName = toRelativeNormalizedFileName(fileName);
const currentFileSource = currentRelativeFiles.get(relativeFileName);
if (currentFileSource === undefined && fileName !== '') {
// An empty file name means the test runner cannot report test file locations.
// If the current file is undefined while the test runner can report test file locations, it means it has been deleted
log.debug('Test file removed: %s', relativeFileName);
testStatisticsCollector.count(relativeFileName, 'removed', oldTestFile.tests.length);
}
else if (currentFileSource !== undefined &&
oldTestFile.source !== undefined) {
log.trace('Diffing %s', relativeFileName);
const locatedTests = closeLocations(oldTestFile);
const { results, removeCount } = performFileDiff(oldTestFile.source, currentFileSource, locatedTests);
testStatisticsCollector.count(relativeFileName, 'removed', removeCount);
results.forEach((test) => {
const key = testToIdentifyingKey(test, relativeFileName);
const testInfo = { key, test, relativeFileName };
byId.set(test.id, testInfo);
byKey.set(key, testInfo);
});
}
else {
// No sources to compare, we should do our best with the info we do have
oldTestFile.tests.map((test) => {
const key = testToIdentifyingKey(test, relativeFileName);
const testInfo = { key, test, relativeFileName };
byId.set(test.id, testInfo);
byKey.set(key, testInfo);
});
}
});
return { byId, byKey };
}
function collectOldKilledAndCoverageMatrix() {
const oldCoverageByMutantKey = new Map();
const oldKilledByMutantKey = new Map();
for (const [key, mutant] of reusableMutantsByKey) {
const killedRow = new Set(mutant.killedBy
?.map((testId) => oldTestsById.get(testId)?.key)
.filter(notEmpty));
const coverageRow = new Set(mutant.coveredBy
?.map((testId) => oldTestsById.get(testId)?.key)
.filter(notEmpty));
killedRow.forEach((killed) => coverageRow.add(killed));
oldCoverageByMutantKey.set(key, coverageRow);
oldKilledByMutantKey.set(key, killedRow);
}
return { oldCoverageByMutantKey, oldKilledByMutantKey };
}
function collectCurrentTestInfo() {
const byTestKey = new Map();
for (const testResult of testCoverage.testsById.values()) {
const relativeFileName = toRelativeNormalizedFileName(testResult.fileName);
const key = testToIdentifyingKey(testResult, relativeFileName);
const info = { relativeFileName, test: testResult, key: key };
byTestKey.set(key, info);
}
return byTestKey;
}
function mutantCanBeReused(mutant, oldMutant, mutantKey, coveringTests, oldKillingTests) {
if (!testCoverage.hasCoverage) {
// This is the best we can do when the test runner didn't report coverage.
// We assume that all mutant test results can be reused,
// End users can use --force to force retesting of certain mutants
return true;
}
if (oldMutant.status === 'Ignored') {
// Was previously ignored, but not anymore, we need to run it now
return false;
}
const testsDiff = diffTestCoverage(mutant.id, oldCoverageTestKeysByMutantKey.get(mutantKey), coveringTests);
if (oldMutant.status === 'Killed') {
if (oldKillingTests) {
for (const killingTest of oldKillingTests) {
if (testsDiff.get(killingTest) === 'same') {
return true;
}
}
}
// Killing tests has changed or no longer exists
return false;
}
for (const action of testsDiff.values()) {
if (action === 'added') {
// A non-killed mutant got a new test, we need to run it
return false;
}
}
// A non-killed mutant did not get new tests, no need to rerun it
return true;
}
/**
* Determines if there is a diff between old test coverage and new test coverage.
*/
function diffTestCoverage(mutantId, oldCoveringTestKeys, newCoveringTests) {
const result = new Map();
if (newCoveringTests) {
for (const newTest of newCoveringTests) {
const key = testToIdentifyingKey(newTest, toRelativeNormalizedFileName(newTest.fileName));
result.set(key, oldCoveringTestKeys?.has(key) ? 'same' : 'added');
}
}
if (oldCoveringTestKeys) {
const isStatic = testCoverage.hasStaticCoverage(mutantId);
for (const oldTestKey of oldCoveringTestKeys) {
if (!result.has(oldTestKey)) {
// Static mutants might not have covering tests, but the test might still exist
if (isStatic && newTestKeys.has(oldTestKey)) {
result.set(oldTestKey, 'same');
}
else {
result.set(oldTestKey, 'removed');
}
}
}
}
return result;
}
}
}
/**
* Finds the diff of mutants and tests. Removes mutants / tests that no longer exist (changed or removed). Updates locations of mutants or tests that do still exist.
* @param oldCode The old code to use for the diff
* @param newCode The new (current) code to use for the diff
* @param items The mutants or tests to be looked . These will be treated as immutable.
* @returns A list of items with updated locations, without items that are changed.
*/
function performFileDiff(oldCode, newCode, items) {
const oldSourceNormalized = normalizeLineEndings(oldCode);
const currentSrcNormalized = normalizeLineEndings(newCode);
const diffChanges = diffMatchPatch.diff_main(oldSourceNormalized, currentSrcNormalized);
const toDo = new Set(items.map((m) => ({ ...m, location: deepClone(m.location) })));
const [added, removed] = [1, -1];
const done = [];
const currentPosition = { column: 0, line: 0 };
let removeCount = 0;
for (const [change, text] of diffChanges) {
if (toDo.size === 0) {
// There are more changes, but nothing left to update.
break;
}
const offset = calculateOffset(text);
if (change === added) {
for (const test of toDo) {
const { location } = test;
if (gte(currentPosition, location.start) &&
gte(location.end, currentPosition)) {
// This item cannot be reused, code was added here
removeCount++;
toDo.delete(test);
}
else {
locationAdd(location, offset, currentPosition.line === location.start.line);
}
}
positionMove(currentPosition, offset);
}
else if (change === removed) {
for (const item of toDo) {
const { location: { start }, } = item;
const endOffset = positionMove({ ...currentPosition }, offset);
if (gte(endOffset, start)) {
// This item cannot be reused, the code it covers has changed
removeCount++;
toDo.delete(item);
}
else {
locationAdd(item.location, negate(offset), currentPosition.line === start.line);
}
}
}
else {
positionMove(currentPosition, offset);
toDo.forEach((item) => {
const { end } = item.location;
if (gte(currentPosition, end)) {
// We're done with this item, it can be reused
toDo.delete(item);
done.push(item);
}
});
}
}
done.push(...toDo);
return { results: done, removeCount };
}
/**
* A greater-than-equals implementation for positions
*/
function gte(a, b) {
return a.line > b.line || (a.line === b.line && a.column >= b.column);
}
function locationIncluded(haystack, needle) {
const startIncluded = gte(needle.start, haystack.start);
const endIncluded = gte(haystack.end, needle.end);
return startIncluded && endIncluded;
}
function deepClone(loc) {
return { start: { ...loc.start }, end: { ...loc.end } };
}
/**
* Reduces a mutant to a string that identifies the mutant across reports.
* Consists of the relative file name, mutator name, replacement, and location
*/
function mutantToIdentifyingKey({ mutatorName, replacement, location: { start, end }, }, relativeFileName) {
return `${relativeFileName}@${start.line}:${start.column}-${end.line}:${end.column}\n${mutatorName}: ${replacement}`;
}
function testToIdentifyingKey({ name, location, startPosition, }, relativeFileName) {
startPosition = startPosition ?? location?.start ?? { line: 0, column: 0 };
return `${relativeFileName}@${startPosition.line}:${startPosition.column}\n${name}`;
}
export function toRelativeNormalizedFileName(fileName) {
return normalizeFileName(path.relative(process.cwd(), fileName ?? ''));
}
function calculateOffset(text) {
const pos = { line: 0, column: 0 };
for (const char of text) {
if (char === '\n') {
pos.line++;
pos.column = 0;
}
else {
pos.column++;
}
}
return pos;
}
function positionMove(pos, diff) {
pos.line += diff.line;
if (diff.line === 0) {
pos.column += diff.column;
}
else {
pos.column = diff.column;
}
return pos;
}
function locationAdd({ start, end }, { line, column }, currentLine) {
start.line += line;
if (currentLine) {
start.column += column;
}
end.line += line;
if (line === 0 && currentLine) {
end.column += column;
}
}
function negate({ line, column }) {
return { line: -1 * line, column: -1 * column };
}
/**
* Sets the end position of each test to the start position of the next test.
* This is an educated guess and necessary.
* If a test has no location, it is assumed it spans the entire file (line 0 to Infinity)
*
* Knowing the end location of tests is necessary in order to know if the test was changed.
*/
function closeLocations(testFile) {
const locatedTests = [];
const openEndedTests = [];
testFile.tests.forEach((test) => {
if (testHasLocation(test)) {
if (isClosed(test)) {
locatedTests.push(test);
}
else {
openEndedTests.push(test);
}
}
else {
locatedTests.push({
...test,
location: {
start: { line: 0, column: 0 },
end: { line: Number.POSITIVE_INFINITY, column: 0 },
},
});
}
});
if (openEndedTests.length) {
// Sort the opened tests in order to close their locations
openEndedTests.sort((a, b) => a.location.start.line - b.location.start.line);
const openEndedTestSet = new Set(openEndedTests);
const startPositions = uniqueStartPositions(openEndedTests);
let currentPositionIndex = 0;
openEndedTestSet.forEach((test) => {
if (eqPosition(test.location.start, startPositions[currentPositionIndex])) {
currentPositionIndex++;
}
if (startPositions[currentPositionIndex]) {
locatedTests.push({
...test,
location: {
start: test.location.start,
end: startPositions[currentPositionIndex],
},
});
openEndedTestSet.delete(test);
}
});
// Don't forget about the last tests
openEndedTestSet.forEach((lastTest) => {
locatedTests.push({
...lastTest,
location: {
start: lastTest.location.start,
end: { line: Number.POSITIVE_INFINITY, column: 0 },
},
});
});
}
return locatedTests;
}
/**
* Determines the unique start positions of a sorted list of tests in order
*/
function uniqueStartPositions(sortedTests) {
let current;
const startPositions = sortedTests.reduce((collector, { location: { start } }) => {
if (!current ||
current.line !== start.line ||
current.column !== start.column) {
current = start;
collector.push(current);
}
return collector;
}, []);
return startPositions;
}
function testHasLocation(test) {
return !!test.location?.start;
}
function isClosed(test) {
return !!test.location.end;
}
function eqPosition(start, end) {
return start.column === end?.column && start.line === end.line;
}
//# sourceMappingURL=incremental-differ.js.map