@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
769 lines (715 loc) • 25.2 kB
text/typescript
import path from 'path';
import { diff_match_patch as DiffMatchPatch } from 'diff-match-patch';
import chalk from 'chalk';
import {
schema,
Mutant,
Position,
Location,
StrykerOptions,
FileDescriptions,
MutateDescription,
} from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
import { TestResult, TestStatus } from '@stryker-mutator/api/test-runner';
import {
I,
normalizeFileName,
normalizeLineEndings,
notEmpty,
} from '@stryker-mutator/util';
import { TestDefinition } from 'mutation-testing-report-schema';
import { commonTokens } from '@stryker-mutator/api/plugin';
import {
DiffChange,
DiffStatisticsCollector,
} from './diff-statistics-collector.js';
import { TestCoverage } from './test-coverage.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 {
public mutantStatisticsCollector: DiffStatisticsCollector | undefined;
public testStatisticsCollector: DiffStatisticsCollector | undefined;
private readonly mutateDescriptionByRelativeFileName: Map<
string,
MutateDescription
>;
public static inject = [
commonTokens.logger,
commonTokens.options,
commonTokens.fileDescriptions,
] as const;
constructor(
private readonly logger: Logger,
private readonly options: StrykerOptions,
fileDescriptions: FileDescriptions,
) {
this.mutateDescriptionByRelativeFileName = new Map(
Object.entries(fileDescriptions).map(([name, description]) => [
toRelativeNormalizedFileName(name),
description.mutate,
]),
);
}
private isInMutatedScope(
relativeFileName: string,
mutant: schema.MutantResult,
): boolean {
const mutate =
this.mutateDescriptionByRelativeFileName.get(relativeFileName);
return (
mutate === true ||
(Array.isArray(mutate) &&
mutate.some((range) => locationIncluded(range, mutant.location)))
);
}
public diff(
currentMutants: readonly Mutant[],
testCoverage: I<TestCoverage>,
incrementalReport: schema.MutationTestResult,
currentRelativeFiles: Map<string, string>,
): readonly Mutant[] {
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: TestResult = {
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<string>();
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: Iterable<string> | undefined) {
return [...(testKeys ?? [])]
.map((id) => testInfoByKey.get(id))
.filter(notEmpty)
.map(({ test: { id } }) => id);
}
function collectReusableMutantsByKey(log: Logger) {
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: Logger) {
const byId = new Map<
string,
{ relativeFileName: string; test: TestDefinition; key: string }
>();
const byKey = new Map<string, TestInfo>();
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<string, Set<string>>();
const oldKilledByMutantKey = new Map<string, Set<string>>();
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<
string,
{ relativeFileName: string; test: TestResult }
>();
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: Mutant,
oldMutant: schema.MutantResult,
mutantKey: string,
coveringTests: ReadonlySet<TestResult> | undefined,
oldKillingTests: Set<string> | undefined,
): boolean {
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: string,
oldCoveringTestKeys: Set<string> | undefined,
newCoveringTests: ReadonlySet<TestResult> | undefined,
): Map<string, DiffAction> {
const result = new Map<string, DiffAction>();
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<T extends { location: Location }>(
oldCode: string,
newCode: string,
items: T[],
): { results: T[]; removeCount: number } {
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: T[] = [];
const currentPosition: Position = { 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: Position, b: Position) {
return a.line > b.line || (a.line === b.line && a.column >= b.column);
}
function locationIncluded(haystack: Location, needle: Location) {
const startIncluded = gte(needle.start, haystack.start);
const endIncluded = gte(haystack.end, needle.end);
return startIncluded && endIncluded;
}
function deepClone(loc: Location): Location {
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 },
}: Pick<Mutant, 'location' | 'mutatorName'> & { replacement?: string },
relativeFileName: string,
) {
return `${relativeFileName}@${start.line}:${start.column}-${end.line}:${end.column}\n${mutatorName}: ${replacement}`;
}
function testToIdentifyingKey(
{
name,
location,
startPosition,
}: Pick<schema.TestDefinition, 'location' | 'name'> &
Pick<TestResult, 'startPosition'>,
relativeFileName: string | undefined,
) {
startPosition = startPosition ?? location?.start ?? { line: 0, column: 0 };
return `${relativeFileName}@${startPosition.line}:${startPosition.column}\n${name}`;
}
export function toRelativeNormalizedFileName(
fileName: string | undefined,
): string {
return normalizeFileName(path.relative(process.cwd(), fileName ?? ''));
}
function calculateOffset(text: string): Position {
const pos: Position = { 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: Position, diff: Position): Position {
pos.line += diff.line;
if (diff.line === 0) {
pos.column += diff.column;
} else {
pos.column = diff.column;
}
return pos;
}
function locationAdd(
{ start, end }: Location,
{ line, column }: Position,
currentLine: boolean,
) {
start.line += line;
if (currentLine) {
start.column += column;
}
end.line += line;
if (line === 0 && currentLine) {
end.column += column;
}
}
function negate({ line, column }: Position): Position {
return { line: -1 * line, column: -1 * column };
}
interface TestInfo {
relativeFileName: string;
test: TestDefinition;
key: string;
}
type DiffAction = DiffChange | 'same';
/**
* 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: schema.TestFile): LocatedTest[] {
const locatedTests: LocatedTest[] = [];
const openEndedTests: OpenEndedTest[] = [];
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: OpenEndedTest[]) {
let current: Position | undefined;
const startPositions = sortedTests.reduce<Position[]>(
(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: schema.TestDefinition): test is OpenEndedTest {
return !!test.location?.start;
}
function isClosed(test: Required<schema.TestDefinition>): test is LocatedTest {
return !!test.location.end;
}
function eqPosition(start: Position, end?: Position): boolean {
return start.column === end?.column && start.line === end.line;
}
type LocatedTest = schema.TestDefinition & { location: Location };
type OpenEndedTest = schema.TestDefinition & {
location: schema.OpenEndLocation;
};