Skip to content

Commit

Permalink
feat(in place): support in place mutation (#2706)
Browse files Browse the repository at this point in the history
Add `--inPlace` flag (or equivalent in stryker.conf.json file). When `inPlace` is `true` (default is `false`), mutations are written "in place", overriding the user's own files. This way, Stryker can support more exotic project configurations, where even copying to a temp directory breaks the test run. For example, when a project relies on [app-root-path](https://www.npmjs.com/package/app-root-path) or uses the [`NODE_PATH`](https://nodejs.org/dist/latest-v14.x/docs/api/modules.html#modules_loading_from_the_global_folders) environment variable.

Support for `--inPlace` added in the `Sandbox` class. For each file that changes, it writes a backup to a ".stryker-tmp/backup123" directory. After mutation testing completes, the backup files are recovered from the backup directory. 

Stryker tries its best to recover the backup files and when the process exits unexpectantly, for example, using `CTRL+C` or by closing the window. This behavior is a best-effort since Stryker can't do anything for you when you kill it from the task manager or with `kill -9`.
  • Loading branch information
nicojs committed Jan 22, 2021
1 parent 36d8d51 commit 2685a7e
Show file tree
Hide file tree
Showing 41 changed files with 820 additions and 270 deletions.
65 changes: 0 additions & 65 deletions .vscode/launch.json
Expand Up @@ -4,71 +4,6 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${file}",
"skipFiles": [
"<node_internals>/**"
]
},
{
"type": "node",
"request": "launch",
"name": "Stryker unit tests",
"program": "${workspaceRoot}/node_modules/mocha/bin/_mocha",
"args": [
"--timeout",
"999999",
"--colors",
"${workspaceRoot}/packages/stryker/test/helpers",
"${workspaceRoot}/packages/stryker/test/unit/**/*.js"
],
"internalConsoleOptions": "openOnSessionStart"
},
{
"type": "node",
"request": "launch",
"name": "Stryker-mocha-framework unit tests",
"program": "${workspaceRoot}/node_modules/mocha/bin/_mocha",
"args": [
"--timeout",
"999999",
"--colors",
"${workspaceRoot}/packages/stryker-mocha-framework/test/helpers",
"${workspaceRoot}/packages/stryker-mocha-framework/test/unit/**/*.js"
],
"internalConsoleOptions": "openOnSessionStart"
},
{
"type": "node",
"request": "launch",
"name": "babel-transpiler unit tests",
"program": "${workspaceRoot}/node_modules/mocha/bin/_mocha",
"args": [
"--timeout",
"999999",
"--colors",
"${workspaceRoot}/packages/stryker-babel-transpiler/test/helpers",
"${workspaceRoot}/packages/stryker-babel-transpiler/test/unit/**/*.js"
],
"internalConsoleOptions": "openOnSessionStart"
},
{
"type": "node",
"request": "launch",
"name": "javascript-mutator unit tests",
"program": "${workspaceRoot}/node_modules/mocha/bin/_mocha",
"args": [
"--timeout",
"999999",
"--colors",
"${workspaceRoot}/packages/stryker-javascript-mutator/test/helpers",
"${workspaceRoot}/packages/stryker-javascript-mutator/test/unit/**/*.js"
],
"internalConsoleOptions": "openOnSessionStart"
},
{
"type": "node",
"request": "attach",
Expand Down
13 changes: 13 additions & 0 deletions docs/configuration.md
Expand Up @@ -145,6 +145,19 @@ When using the config file you can provide an array with `string`s

You can *ignore* files by adding an exclamation mark (`!`) at the start of an expression.

### `inPlace` [`boolean`]

Default: `false`<br />
Command line: `--inPlace`<br />
Config file: `"inPlace": true`<br />

Determines whether or not Stryker should mutate your files in place.
Note: mutating your files in place is generally not needed for mutation testing, unless you have a dependency in your project that is really dependent on the file locations (like "app-root-path" for example).

When `true`, Stryker will override your files, but it will keep a copy of the originals in the temp directory (using `tempDirName`) and it will place the originals back after it is done.

When `false` (default) Stryker will work in the copy of your code inside the temp directory.

### `logLevel` [`string`]

Default: `info`<br />
Expand Down
4 changes: 4 additions & 0 deletions e2e/test/in-place/.mocharc.jsonc
@@ -0,0 +1,4 @@
{
"require": "./test/helpers/testSetup.js",
"spec": ["test/unit/*.js"]
}
5 changes: 5 additions & 0 deletions e2e/test/in-place/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions e2e/test/in-place/package.json
@@ -0,0 +1,15 @@
{
"name": "in-place",
"version": "0.0.0",
"private": true,
"description": "A module to perform an integration test",
"main": "index.js",
"scripts": {
"pretest": "rimraf \"reports\"",
"test": "mocha --no-config --require ../../tasks/ts-node-register.js --no-timeout verify/*.ts",
"test:unit": "mocha",
"test:mutation": "stryker run"
},
"author": "",
"license": "ISC"
}
24 changes: 24 additions & 0 deletions e2e/test/in-place/src/Add.js
@@ -0,0 +1,24 @@
module.exports.add = function(num1, num2) {
return num1 + num2;
};

module.exports.addOne = function(number) {
number++;
return number;
};

module.exports.negate = function(number) {
return -number;
};

module.exports.notCovered = function(number) {
return number > 10;
};

module.exports.isNegativeNumber = function(number) {
var isNegative = false;
if(number < 0){
isNegative = true;
}
return isNegative;
};
15 changes: 15 additions & 0 deletions e2e/test/in-place/stryker.conf.json
@@ -0,0 +1,15 @@
{
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "mocha",
"concurrency": 2,
"coverageAnalysis": "perTest",
"reporters": [
"clear-text",
"html",
"event-recorder"
],
"plugins": [
"@stryker-mutator/mocha-runner"
],
"inPlace": true
}
5 changes: 5 additions & 0 deletions e2e/test/in-place/test/helpers/testSetup.js
@@ -0,0 +1,5 @@
exports.mochaHooks = {
beforeAll() {
global.expect = require('chai').expect;
}
}
52 changes: 52 additions & 0 deletions e2e/test/in-place/test/unit/Add.spec.js
@@ -0,0 +1,52 @@
var addModule = require('../../src/Add');
var add = addModule.add;
var addOne = addModule.addOne;
var isNegativeNumber = addModule.isNegativeNumber;
var negate = addModule.negate;
var notCovered = addModule.notCovered;

describe('Add', function() {
it('should be able to add two numbers', function() {
var num1 = 2;
var num2 = 5;
var expected = num1 + num2;

var actual = add(num1, num2);

expect(actual).to.be.equal(expected);
});

it('should be able 1 to a number', function() {
var number = 2;
var expected = 3;

var actual = addOne(number);

expect(actual).to.be.equal(expected);
});

it('should be able negate a number', function() {
var number = 2;
var expected = -2;

var actual = negate(number);

expect(actual).to.be.equal(expected);
});

it('should be able to recognize a negative number', function() {
var number = -2;

var isNegative = isNegativeNumber(number);

expect(isNegative).to.be.true;
});

it('should be able to recognize that 0 is not a negative number', function() {
var number = 0;

var isNegative = isNegativeNumber(number);

expect(isNegative).to.be.false;
});
});
13 changes: 13 additions & 0 deletions e2e/test/in-place/test/unit/Wait.spec.js
@@ -0,0 +1,13 @@
const { existsSync } = require('fs');
const path = require('path');

describe('wait', () =>{
it('should wait until `.lock` is removed', async () => {
while(existsSync(path.resolve(__dirname, '..', '..', '.lock'))){
await sleep(10);
}
});
})
async function sleep(n) {
return new Promise(res => setTimeout(res, n));
}
69 changes: 69 additions & 0 deletions e2e/test/in-place/verify/verify.ts
@@ -0,0 +1,69 @@
import { expectMetrics } from '../../../helpers';
import { promises as fsPromises } from 'fs';
import chai from 'chai';
import execa from 'execa';
import rimraf from 'rimraf';
import path from 'path';
import { promisify } from 'util';
import { it } from 'mocha';
import chaiAsPromised from 'chai-as-promised';

chai.use(chaiAsPromised);
const expect = chai.expect;

const rm = promisify(rimraf);

const rootResolve: typeof path.resolve = path.resolve.bind(path, __dirname, '..');

describe('in place', () => {

let originalAddJSContent: string;


function readAddJS(): Promise<string> {
return fsPromises.readFile(rootResolve('src', 'Add.js'), 'utf-8');
}

before(async () => {
originalAddJSContent = await readAddJS();
})


afterEach(async () => {
await rm(rootResolve('reports'));
await rm(rootResolve('.lock'));
})

it('should reset files after a successful run', async () => {
execa.sync('stryker', ['run']);
const addJSContent = await fsPromises.readFile(rootResolve('src', 'Add.js'), 'utf-8');
expect(addJSContent).eq(originalAddJSContent);
});

it('should report correct score', async () => {
execa.sync('stryker', ['run']);
await expectMetrics({ mutationScore: 73.68 });
});

it('should also reset the files if Stryker exits unexpectedly', async () => {
// Arrange
let addJSMutatedContent: string;
await fsPromises.writeFile(rootResolve('.lock'), ''); // this will lock the test run completion
const onGoingStrykerRun = execa('node', [path.resolve('..', '..', 'node_modules', '.bin', 'stryker'), 'run']);
onGoingStrykerRun.stdout.on('data', async (data) => {
if (data.toString().includes('Starting initial test run')) {
addJSMutatedContent = await readAddJS();

// Now, mr bond, it is time to die!
onGoingStrykerRun.kill();
}
});

// Act
await expect(onGoingStrykerRun).rejected;

// Assert
expect(await readAddJS()).eq(originalAddJSContent);
expect(addJSMutatedContent).not.eq(originalAddJSContent);
});
});
8 changes: 6 additions & 2 deletions e2e/test/karma-mocha/package.json
Expand Up @@ -6,8 +6,12 @@
"main": "index.js",
"scripts": {
"pretest": "rimraf \"reports\"",
"test": "stryker run stryker.conf.js",
"posttest": "mocha --require ../../tasks/ts-node-register.js verify/*.ts"
"test": "npm run test:not-in-place && npm run test:in-place",
"test:not-in-place": "stryker run stryker.conf.js",
"posttest:not-in-place": "npm run verify",
"test:in-place": "stryker run stryker.conf.js --inPlace",
"posttest:in-place": "npm run verify",
"verify": "mocha --require ../../tasks/ts-node-register.js verify/*.ts"
},
"author": "",
"license": "ISC"
Expand Down
5 changes: 5 additions & 0 deletions packages/api/schema/stryker-core.json
Expand Up @@ -294,6 +294,11 @@
"type": "string"
}
},
"inPlace": {
"type": "boolean",
"description": "Determines whether or not Stryker should mutate your files in place. Note: mutating your files in place is generally not needed for mutation testing, unless you have a dependency in your project that is really dependent on the file locations (like \"app-root-path\" for example).\n\nWhen `true`, Stryker will override your files, but it will keep a copy of the originals in the temp directory (using `tempDirName`) and it will place the originals back after it is done.\n\nWhen `false` (default) Stryker will work in the copy of your code inside the temp directory.",
"default": false
},
"logLevel": {
"description": "Set the log level that Stryker uses to write to the console.",
"$ref": "#/definitions/logLevel",
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/di/build-main-injector.ts
Expand Up @@ -9,6 +9,7 @@ import ConfigReader from '../config/config-reader';
import BroadcastReporter from '../reporters/broadcast-reporter';
import { TemporaryDirectory } from '../utils/temporary-directory';
import Timer from '../utils/timer';
import { UnexpectedExitHandler } from '../unexpected-exit-handler';

import { pluginResolverFactory } from './factory-methods';

Expand All @@ -21,11 +22,13 @@ export interface MainContext extends PluginContext {
[coreTokens.timer]: I<Timer>;
[coreTokens.temporaryDirectory]: I<TemporaryDirectory>;
[coreTokens.execa]: typeof execa;
[coreTokens.process]: NodeJS.Process;
[coreTokens.unexpectedExitRegistry]: I<UnexpectedExitHandler>;
}

type PluginResolverProvider = Injector<PluginContext>;
export type CliOptionsProvider = Injector<Pick<MainContext, 'logger' | 'getLogger'> & { [coreTokens.cliOptions]: PartialStrykerOptions }>;

export type CliOptionsProvider = Injector<Pick<MainContext, 'logger' | 'getLogger'> & { [coreTokens.cliOptions]: PartialStrykerOptions }>;
buildMainInjector.inject = tokens(commonTokens.injector);
export function buildMainInjector(injector: CliOptionsProvider): Injector<MainContext> {
const pluginResolverProvider = createPluginResolverProvider(injector);
Expand All @@ -35,7 +38,9 @@ export function buildMainInjector(injector: CliOptionsProvider): Injector<MainCo
.provideClass(coreTokens.reporter, BroadcastReporter)
.provideClass(coreTokens.temporaryDirectory, TemporaryDirectory)
.provideClass(coreTokens.timer, Timer)
.provideValue(coreTokens.execa, execa);
.provideValue(coreTokens.execa, execa)
.provideValue(coreTokens.process, process)
.provideClass(coreTokens.unexpectedExitRegistry, UnexpectedExitHandler);
}

export function createPluginResolverProvider(parent: CliOptionsProvider): PluginResolverProvider {
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/di/core-tokens.ts
Expand Up @@ -5,21 +5,19 @@ export const disableTypeChecksHelper = 'disableTypeChecksHelper';
export const execa = 'execa';
export const cliOptions = 'cliOptions';
export const configReader = 'configReader';
export const configOptionsApplier = 'configOptionsApplier'; // TODO: Remove if unused post mutation switching
export const inputFiles = 'inputFiles';
export const dryRunResult = 'dryRunResult';
export const transpiledFiles = 'transpiledFiles'; // TODO: Remove if unused post mutation switching
export const files = 'files';
export const mutants = 'mutants';
export const mutantsWithTestCoverage = 'mutantsWithTestCoverage';
export const mutantTranspileScheduler = 'mutantTranspileScheduler'; // TODO: Remove if unused post mutation switching
export const process = 'process';
export const temporaryDirectory = 'temporaryDirectory';
export const unexpectedExitRegistry = 'unexpectedExitRegistry';
export const timer = 'timer';
export const timeOverheadMS = 'timeOverheadMS';
export const loggingContext = 'loggingContext';
export const mutationTestReportHelper = 'mutationTestReportHelper';
export const sandbox = 'sandbox';
export const sandboxWorkingDirectory = 'sandboxWorkingDirectory'; // TODO: Remove if unused post mutation switching
export const concurrencyTokenProvider = 'concurrencyTokenProvider';
export const testRunnerFactory = 'testRunnerFactory';
export const testRunnerPool = 'testRunnerPool';
Expand Down

0 comments on commit 2685a7e

Please sign in to comment.