@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
503 lines • 25 kB
JavaScript
import path from 'path';
import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
import chalk from 'chalk';
import { MutantStatus } from '@stryker-mutator/api/core';
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 {
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) {
var _a, _b, _c;
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 === null || location === void 0 ? void 0 : 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 !== null && coveringTests !== void 0 ? 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 = (_a = oldCoverageTestKeysByMutantKey.get(mutantKey)) !== null && _a !== void 0 ? _a : [];
const killed = (_b = oldKilledTestKeysByMutantKey.get(mutantKey)) !== null && _b !== void 0 ? _b : [];
const coveredBy = testKeysToId(coverage);
const killedBy = testKeysToId(killed);
const reusedMutant = {
...oldResult,
id: mutantKey,
fileName: path.resolve(oldResult.relativeFileName),
replacement: (_c = oldResult.replacement) !== null && _c !== void 0 ? _c : 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 !== null && testKeys !== void 0 ? 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 !== null && testFiles !== void 0 ? 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() {
var _a, _b;
const oldCoverageByMutantKey = new Map();
const oldKilledByMutantKey = new Map();
for (const [key, mutant] of reusableMutantsByKey) {
const killedRow = new Set((_a = mutant.killedBy) === null || _a === void 0 ? void 0 : _a.map((testId) => { var _a; return (_a = oldTestsById.get(testId)) === null || _a === void 0 ? void 0 : _a.key; }).filter(notEmpty));
const coverageRow = new Set((_b = mutant.coveredBy) === null || _b === void 0 ? void 0 : _b.map((testId) => { var _a; return (_a = oldTestsById.get(testId)) === null || _a === void 0 ? void 0 : _a.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 === MutantStatus.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 === MutantStatus.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 === null || oldCoveringTestKeys === void 0 ? void 0 : 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;
}
}
}
IncrementalDiffer.inject = [commonTokens.logger, commonTokens.options, commonTokens.fileDescriptions];
/**
* 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) {
var _a;
startPosition = (_a = startPosition !== null && startPosition !== void 0 ? startPosition : location === null || location === void 0 ? void 0 : location.start) !== null && _a !== void 0 ? _a : { line: 0, column: 0 };
return `${relativeFileName}@${startPosition.line}:${startPosition.column}\n${name}`;
}
export function toRelativeNormalizedFileName(fileName) {
return normalizeFileName(path.relative(process.cwd(), fileName !== null && fileName !== void 0 ? 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) {
var _a;
return !!((_a = test.location) === null || _a === void 0 ? void 0 : _a.start);
}
function isClosed(test) {
return !!test.location.end;
}
function eqPosition(start, end) {
return start.column === (end === null || end === void 0 ? void 0 : end.column) && start.line === end.line;
}
//# sourceMappingURL=incremental-differ.js.map