diff --git a/.vscode/launch.json b/.vscode/launch.json index 53d2180992..701aa9df9b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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": [ - "/**" - ] - }, - { - "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", diff --git a/docs/configuration.md b/docs/configuration.md index 4e1523c524..8893096a4f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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`
+Command line: `--inPlace`
+Config file: `"inPlace": true`
+ +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`
diff --git a/e2e/test/in-place/.mocharc.jsonc b/e2e/test/in-place/.mocharc.jsonc new file mode 100644 index 0000000000..3c2e5a6a9d --- /dev/null +++ b/e2e/test/in-place/.mocharc.jsonc @@ -0,0 +1,4 @@ +{ + "require": "./test/helpers/testSetup.js", + "spec": ["test/unit/*.js"] +} \ No newline at end of file diff --git a/e2e/test/in-place/package-lock.json b/e2e/test/in-place/package-lock.json new file mode 100644 index 0000000000..3900182e5c --- /dev/null +++ b/e2e/test/in-place/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "mocha-mocha", + "version": "0.0.0", + "lockfileVersion": 1 +} diff --git a/e2e/test/in-place/package.json b/e2e/test/in-place/package.json new file mode 100644 index 0000000000..fa3e3eb5a7 --- /dev/null +++ b/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" +} diff --git a/e2e/test/in-place/src/Add.js b/e2e/test/in-place/src/Add.js new file mode 100644 index 0000000000..d7c5f6baae --- /dev/null +++ b/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; +}; diff --git a/e2e/test/in-place/stryker.conf.json b/e2e/test/in-place/stryker.conf.json new file mode 100644 index 0000000000..4b88ff44b4 --- /dev/null +++ b/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 +} diff --git a/e2e/test/in-place/test/helpers/testSetup.js b/e2e/test/in-place/test/helpers/testSetup.js new file mode 100644 index 0000000000..993b68e6fc --- /dev/null +++ b/e2e/test/in-place/test/helpers/testSetup.js @@ -0,0 +1,5 @@ +exports.mochaHooks = { + beforeAll() { + global.expect = require('chai').expect; + } +} diff --git a/e2e/test/in-place/test/unit/Add.spec.js b/e2e/test/in-place/test/unit/Add.spec.js new file mode 100644 index 0000000000..d37ba16e88 --- /dev/null +++ b/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; + }); +}); diff --git a/e2e/test/in-place/test/unit/Wait.spec.js b/e2e/test/in-place/test/unit/Wait.spec.js new file mode 100644 index 0000000000..c40cd26a55 --- /dev/null +++ b/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)); +} diff --git a/e2e/test/in-place/verify/verify.ts b/e2e/test/in-place/verify/verify.ts new file mode 100644 index 0000000000..2c86ff4e62 --- /dev/null +++ b/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 { + 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); + }); +}); diff --git a/e2e/test/karma-mocha/package.json b/e2e/test/karma-mocha/package.json index df972b3668..025364661e 100644 --- a/e2e/test/karma-mocha/package.json +++ b/e2e/test/karma-mocha/package.json @@ -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" diff --git a/packages/api/schema/stryker-core.json b/packages/api/schema/stryker-core.json index a07e5a68d3..b90ae5a51e 100644 --- a/packages/api/schema/stryker-core.json +++ b/packages/api/schema/stryker-core.json @@ -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", diff --git a/packages/core/src/di/build-main-injector.ts b/packages/core/src/di/build-main-injector.ts index a36c5cab90..641773ecc5 100644 --- a/packages/core/src/di/build-main-injector.ts +++ b/packages/core/src/di/build-main-injector.ts @@ -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'; @@ -21,11 +22,13 @@ export interface MainContext extends PluginContext { [coreTokens.timer]: I; [coreTokens.temporaryDirectory]: I; [coreTokens.execa]: typeof execa; + [coreTokens.process]: NodeJS.Process; + [coreTokens.unexpectedExitRegistry]: I; } type PluginResolverProvider = Injector; -export type CliOptionsProvider = Injector & { [coreTokens.cliOptions]: PartialStrykerOptions }>; +export type CliOptionsProvider = Injector & { [coreTokens.cliOptions]: PartialStrykerOptions }>; buildMainInjector.inject = tokens(commonTokens.injector); export function buildMainInjector(injector: CliOptionsProvider): Injector { const pluginResolverProvider = createPluginResolverProvider(injector); @@ -35,7 +38,9 @@ export function buildMainInjector(injector: CliOptionsProvider): Injector; [coreTokens.mutants]: readonly Mutant[]; [coreTokens.checkerPool]: I>; [coreTokens.concurrencyTokenProvider]: I; @@ -90,10 +89,7 @@ export class DryRunExecutor { .provideValue(coreTokens.testRunnerConcurrencyTokens, this.concurrencyTokenProvider.testRunnerToken$) .provideFactory(coreTokens.testRunnerPool, createTestRunnerPool); const testRunnerPool = testRunnerInjector.resolve(coreTokens.testRunnerPool); - const { dryRunResult, timing } = await testRunnerPool - .schedule(of(0), (testRunner) => this.timeDryRun(testRunner)) - .pipe(first()) - .toPromise(); + const { dryRunResult, timing } = await testRunnerPool.schedule(of(0), (testRunner) => this.timeDryRun(testRunner)).toPromise(); this.logInitialTestRunSucceeded(dryRunResult.tests, timing); if (!dryRunResult.tests.length) { diff --git a/packages/core/src/sandbox/sandbox.ts b/packages/core/src/sandbox/sandbox.ts index 8dc4ad29e5..8b3f901f4b 100644 --- a/packages/core/src/sandbox/sandbox.ts +++ b/packages/core/src/sandbox/sandbox.ts @@ -1,77 +1,63 @@ import path = require('path'); +import { promises as fsPromises } from 'fs'; import execa = require('execa'); import npmRunPath = require('npm-run-path'); import { StrykerOptions } from '@stryker-mutator/api/core'; import { File } from '@stryker-mutator/api/core'; import { normalizeWhitespaces, I } from '@stryker-mutator/util'; -import * as mkdirp from 'mkdirp'; -import { Logger, LoggerFactoryMethod } from '@stryker-mutator/api/logging'; -import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; +import { Logger } from '@stryker-mutator/api/logging'; +import { tokens, commonTokens, Disposable } from '@stryker-mutator/api/plugin'; import { mergeMap, toArray } from 'rxjs/operators'; import { from } from 'rxjs'; import { TemporaryDirectory } from '../utils/temporary-directory'; -import { findNodeModules, MAX_CONCURRENT_FILE_IO, symlinkJunction, writeFile } from '../utils/file-utils'; +import { findNodeModules, MAX_CONCURRENT_FILE_IO, moveDirectoryRecursiveSync, symlinkJunction, mkdirp } from '../utils/file-utils'; import { coreTokens } from '../di'; +import { UnexpectedExitHandler } from '../unexpected-exit-handler'; -interface SandboxFactory { - ( - options: StrykerOptions, - getLogger: LoggerFactoryMethod, - files: readonly File[], - tempDir: I, - exec: typeof execa - ): Promise; - inject: [ - typeof commonTokens.options, - typeof commonTokens.getLogger, - typeof coreTokens.files, - typeof coreTokens.temporaryDirectory, - typeof coreTokens.execa - ]; -} - -export class Sandbox { +export class Sandbox implements Disposable { private readonly fileMap = new Map(); public readonly workingDirectory: string; + private readonly backupDirectory: string = ''; + + public static readonly inject = tokens( + commonTokens.options, + commonTokens.logger, + coreTokens.temporaryDirectory, + coreTokens.files, + coreTokens.execa, + coreTokens.unexpectedExitRegistry + ); - private constructor( + constructor( private readonly options: StrykerOptions, private readonly log: Logger, temporaryDirectory: I, private readonly files: readonly File[], - private readonly exec: typeof execa + private readonly exec: typeof execa, + unexpectedExitHandler: I ) { - this.workingDirectory = temporaryDirectory.createRandomDirectory('sandbox'); - this.log.debug('Creating a sandbox for files in %s', this.workingDirectory); + if (options.inPlace) { + this.workingDirectory = process.cwd(); + this.backupDirectory = temporaryDirectory.createRandomDirectory('backup'); + this.log.info( + 'In place mode is enabled, Stryker will be overriding YOUR files. Find your backup at: %s', + path.relative(process.cwd(), this.backupDirectory) + ); + unexpectedExitHandler.registerHandler(this.dispose.bind(this, true)); + } else { + this.workingDirectory = temporaryDirectory.createRandomDirectory('sandbox'); + this.log.debug('Creating a sandbox for files in %s', this.workingDirectory); + } } - private async initialize(): Promise { + public async init(): Promise { await this.fillSandbox(); await this.runBuildCommand(); await this.symlinkNodeModulesIfNeeded(); } - public static create: SandboxFactory = Object.assign( - async ( - options: StrykerOptions, - getLogger: LoggerFactoryMethod, - files: readonly File[], - tempDir: I, - exec: typeof execa - ): Promise => { - const sandbox = new Sandbox(options, getLogger(Sandbox.name), tempDir, files, exec); - await sandbox.initialize(); - return sandbox; - }, - { inject: tokens(commonTokens.options, commonTokens.getLogger, coreTokens.files, coreTokens.temporaryDirectory, coreTokens.execa) } - ); - - public get sandboxFileNames(): string[] { - return [...this.fileMap.entries()].map(([, to]) => to); - } - public sandboxFileFor(fileName: string): string { const sandboxFileName = this.fileMap.get(fileName); if (sandboxFileName === undefined) { @@ -92,14 +78,14 @@ export class Sandbox { private async runBuildCommand() { if (this.options.buildCommand) { const env = npmRunPath.env(); - this.log.info('Running build command "%s" in the sandbox at "%s".', this.options.buildCommand, this.workingDirectory); + this.log.info('Running build command "%s" in "%s".', this.options.buildCommand, this.workingDirectory); this.log.debug('(using PATH: %s)', env.PATH); await this.exec.command(this.options.buildCommand, { cwd: this.workingDirectory, env }); } } private async symlinkNodeModulesIfNeeded(): Promise { - if (this.options.symlinkNodeModules) { + if (this.options.symlinkNodeModules && !this.options.inPlace) { // TODO: Change with this.options.basePath when we have it const basePath = process.cwd(); const nodeModules = await findNodeModules(basePath); @@ -123,10 +109,34 @@ export class Sandbox { private async fillFile(file: File): Promise { const relativePath = path.relative(process.cwd(), file.name); - const folderName = path.join(this.workingDirectory, path.dirname(relativePath)); - mkdirp.sync(folderName); - const targetFileName = path.join(folderName, path.basename(relativePath)); - this.fileMap.set(file.name, targetFileName); - await writeFile(targetFileName, file.content); + if (this.options.inPlace) { + this.fileMap.set(file.name, file.name); + const originalContent = await fsPromises.readFile(file.name); + if (!originalContent.equals(file.content)) { + // File is changed (either mutated or by a preprocessor), make a backup and replace in-place + const backupFileName = path.join(this.backupDirectory, relativePath); + await mkdirp(path.dirname(backupFileName)); + await fsPromises.writeFile(backupFileName, originalContent); + this.log.debug('Stored backup file at %s', backupFileName); + await fsPromises.writeFile(file.name, file.content); + } + } else { + const folderName = path.join(this.workingDirectory, path.dirname(relativePath)); + await mkdirp(folderName); + const targetFileName = path.join(folderName, path.basename(relativePath)); + this.fileMap.set(file.name, targetFileName); + await fsPromises.writeFile(targetFileName, file.content); + } + } + + public dispose(unexpected = false): void { + if (this.backupDirectory) { + if (unexpected) { + console.error(`Detecting unexpected exit, recovering original files from ${path.relative(process.cwd(), this.backupDirectory)}`); + } else { + this.log.info(`Resetting your original files from ${path.relative(process.cwd(), this.backupDirectory)}.`); + } + moveDirectoryRecursiveSync(this.backupDirectory, this.workingDirectory); + } } } diff --git a/packages/core/src/sandbox/ts-config-preprocessor.ts b/packages/core/src/sandbox/ts-config-preprocessor.ts index d72e7e347d..34d11488f2 100644 --- a/packages/core/src/sandbox/ts-config-preprocessor.ts +++ b/packages/core/src/sandbox/ts-config-preprocessor.ts @@ -30,16 +30,21 @@ export class TSConfigPreprocessor implements FilePreprocessor { constructor(private readonly log: Logger, private readonly options: StrykerOptions) {} public async preprocess(input: File[]): Promise { - const tsconfigFile = path.resolve(this.options.tsconfigFile); - if (input.find((file) => file.name === tsconfigFile)) { - this.fs.clear(); - input.forEach((file) => { - this.fs.set(file.name, file); - }); - await this.rewriteTSConfigFile(tsconfigFile); - return [...this.fs.values()]; - } else { + if (this.options.inPlace) { + // If stryker is running 'inPlace', we don't have to change the tsconfig file return input; + } else { + const tsconfigFile = path.resolve(this.options.tsconfigFile); + if (input.find((file) => file.name === tsconfigFile)) { + this.fs.clear(); + input.forEach((file) => { + this.fs.set(file.name, file); + }); + await this.rewriteTSConfigFile(tsconfigFile); + return [...this.fs.values()]; + } else { + return input; + } } } diff --git a/packages/core/src/stryker-cli.ts b/packages/core/src/stryker-cli.ts index 605f8ccfcb..5ae1ea6f92 100644 --- a/packages/core/src/stryker-cli.ts +++ b/packages/core/src/stryker-cli.ts @@ -139,6 +139,10 @@ export default class StrykerCli { `Send a full report (inc. source code and mutant results) or only the mutation score. Default: ${defaultValues.dashboard.reportType}`, deepOption(dashboard, 'reportType') ) + .option( + '--inPlace', + 'Enable Stryker to mutate your files in place and put back the originals after its done. Note: mutating your files in place is generally not needed for mutation testing.' + ) .option( '--tempDirName ', 'Set the name of the directory that is used by Stryker as a working directory. This directory will be cleaned after a successful run' diff --git a/packages/core/src/test-runner/index.ts b/packages/core/src/test-runner/index.ts index e4fbb3b958..bce828a470 100644 --- a/packages/core/src/test-runner/index.ts +++ b/packages/core/src/test-runner/index.ts @@ -15,7 +15,7 @@ import MaxTestRunnerReuseDecorator from './max-test-runner-reuse-decorator'; createTestRunnerFactory.inject = tokens(commonTokens.options, coreTokens.sandbox, coreTokens.loggingContext); export function createTestRunnerFactory( options: StrykerOptions, - sandbox: Pick, + sandbox: Pick, loggingContext: LoggingClientContext ): () => Required { if (CommandTestRunner.is(options.testRunner)) { diff --git a/packages/core/src/unexpected-exit-handler.ts b/packages/core/src/unexpected-exit-handler.ts new file mode 100644 index 0000000000..f2242d730f --- /dev/null +++ b/packages/core/src/unexpected-exit-handler.ts @@ -0,0 +1,34 @@ +import { Disposable } from '@stryker-mutator/api/plugin'; + +import { coreTokens } from './di'; + +export type ExitHandler = () => void; + +const signals = Object.freeze(['SIGABRT', 'SIGINT', 'SIGHUP', 'SIGTERM']); +export class UnexpectedExitHandler implements Disposable { + private readonly unexpectedExitHandlers: ExitHandler[] = []; + + public static readonly inject = [coreTokens.process] as const; + constructor(private readonly process: Pick) { + process.on('exit', this.handleExit); + signals.forEach((signal) => process.on(signal, this.processSignal)); + } + private readonly processSignal = (_signal: string, signalNumber: number) => { + // Just call 'exit' with correct exitCode. + // See https://nodejs.org/api/process.html#process_signal_events, we should exit with 128 + signal number + this.process.exit(128 + signalNumber); + }; + + private readonly handleExit = () => { + this.unexpectedExitHandlers.forEach((handler) => handler()); + }; + + public registerHandler(handler: ExitHandler) { + this.unexpectedExitHandlers.push(handler); + } + + public dispose(): void { + this.process.off('exit', this.handleExit); + signals.forEach((signal) => this.process.off(signal, this.processSignal)); + } +} diff --git a/packages/core/src/utils/file-utils.ts b/packages/core/src/utils/file-utils.ts index c25416b479..850ffe480b 100644 --- a/packages/core/src/utils/file-utils.ts +++ b/packages/core/src/utils/file-utils.ts @@ -1,14 +1,16 @@ import * as path from 'path'; -import { promises as fs } from 'fs'; +import fs = require('fs'); import { promisify } from 'util'; import * as nodeGlob from 'glob'; -import * as mkdirp from 'mkdirp'; +import mkdirpModule = require('mkdirp'); import * as rimraf from 'rimraf'; export const MAX_CONCURRENT_FILE_IO = 256; +export const mkdirp = mkdirpModule; + export function glob(expression: string): Promise { return new Promise((resolve, reject) => { nodeGlob(expression, { nodir: true }, (error, matches) => { @@ -21,11 +23,11 @@ export const deleteDir = promisify(rimraf); export async function cleanFolder(folderName: string) { try { - await fs.lstat(folderName); + await fs.promises.lstat(folderName); await deleteDir(folderName); - return mkdirp.sync(folderName); + return mkdirp(folderName); } catch (e) { - return mkdirp.sync(folderName); + return mkdirp(folderName); } } @@ -37,17 +39,29 @@ export function importModule(moduleName: string): unknown { } /** - * Writes data to a specified file. - * @param fileName The path to the file. - * @param data The content of the file. - * @returns A promise to eventually save the file. + * Recursively walks the from directory and copy the content to the target directory synchronously + * @param from The source directory to move from + * @param to The target directory to move to */ -export function writeFile(fileName: string, data: string | Buffer): Promise { - if (Buffer.isBuffer(data)) { - return fs.writeFile(fileName, data); - } else { - return fs.writeFile(fileName, data, 'utf8'); +export function moveDirectoryRecursiveSync(from: string, to: string) { + if (!fs.existsSync(from)) { + return; + } + if (!fs.existsSync(to)) { + fs.mkdirSync(to); + } + const files = fs.readdirSync(from); + for (const file of files) { + const fromFileName = path.join(from, file); + const toFileName = path.join(to, file); + const stats = fs.lstatSync(fromFileName); + if (stats.isFile()) { + fs.renameSync(fromFileName, toFileName); + } else { + moveDirectoryRecursiveSync(fromFileName, toFileName); + } } + fs.rmdirSync(from); } /** @@ -56,7 +70,7 @@ export function writeFile(fileName: string, data: string | Buffer): Promise basePath = path.resolve(basePath); const nodeModules = path.resolve(basePath, 'node_modules'); try { - await fs.stat(nodeModules); + await fs.promises.stat(nodeModules); return nodeModules; } catch (e) { const parent = path.dirname(basePath); diff --git a/packages/core/src/utils/object-utils.ts b/packages/core/src/utils/object-utils.ts index c7155ab266..416d9776c9 100644 --- a/packages/core/src/utils/object-utils.ts +++ b/packages/core/src/utils/object-utils.ts @@ -70,3 +70,11 @@ export function padLeft(input: string): string { .map((str) => '\t' + str) .join('\n'); } + +/** + * Creates a random integer number. + * @returns A random integer. + */ +export function random(): number { + return Math.ceil(Math.random() * 10000000); +} diff --git a/packages/core/src/utils/temporary-directory.ts b/packages/core/src/utils/temporary-directory.ts index 9df878a8c5..65c1fb37f8 100644 --- a/packages/core/src/utils/temporary-directory.ts +++ b/packages/core/src/utils/temporary-directory.ts @@ -8,6 +8,7 @@ import * as mkdirp from 'mkdirp'; import { Disposable } from 'typed-inject'; import { deleteDir } from './file-utils'; +import { random } from './object-utils'; export class TemporaryDirectory implements Disposable { private readonly temporaryDirectory: string; @@ -35,7 +36,7 @@ export class TemporaryDirectory implements Disposable { if (!this.isInitialized) { throw new Error('initialize() was not called!'); } - const dir = path.resolve(this.temporaryDirectory, `${prefix}${this.random()}`); + const dir = path.resolve(this.temporaryDirectory, `${prefix}${random()}`); mkdirp.sync(dir); return dir; } @@ -77,12 +78,4 @@ export class TemporaryDirectory implements Disposable { } } } - - /** - * Creates a random integer number. - * @returns A random integer. - */ - public random(): number { - return Math.ceil(Math.random() * 10000000); - } } diff --git a/packages/core/test/integration/test-runner/create-test-runner-factory.it.spec.ts b/packages/core/test/integration/test-runner/create-test-runner-factory.it.spec.ts index f38bc611eb..02a21a622d 100644 --- a/packages/core/test/integration/test-runner/create-test-runner-factory.it.spec.ts +++ b/packages/core/test/integration/test-runner/create-test-runner-factory.it.spec.ts @@ -36,7 +36,7 @@ describe(`${createTestRunnerFactory.name} integration`, () => { testInjector.options.maxTestRunnerReuse = 0; alreadyDisposed = false; createSut = testInjector.injector - .provideValue(coreTokens.sandbox, { sandboxFileNames: ['foo.js'], workingDirectory: __dirname }) + .provideValue(coreTokens.sandbox, { workingDirectory: __dirname }) .provideValue(coreTokens.loggingContext, loggingContext) .injectFunction(createTestRunnerFactory); diff --git a/packages/core/test/integration/utils/file-utils.it.spec.ts b/packages/core/test/integration/utils/file-utils.it.spec.ts new file mode 100644 index 0000000000..020d7cd33b --- /dev/null +++ b/packages/core/test/integration/utils/file-utils.it.spec.ts @@ -0,0 +1,105 @@ +import os = require('os'); +import path = require('path'); +import { promises as fsPromises } from 'fs'; + +import mkdirp = require('mkdirp'); + +import { expect } from 'chai'; +import { File } from '@stryker-mutator/api/core'; +import nodeGlob = require('glob'); +import { assertions } from '@stryker-mutator/test-helpers'; + +import * as fileUtils from '../../../src/utils/file-utils'; + +describe('fileUtils', () => { + describe('glob', () => { + it('should resolve files', () => expect(fileUtils.glob('testResources/globTestFiles/sample/**/*.js')).to.eventually.have.length(10)); + + it('should not resolve to directories', () => + expect(fileUtils.glob('testResources/globTestFiles/notResolveDirs/**/*.js')).to.eventually.have.length(1)); + }); + + describe('moveDirectoryRecursiveSync', () => { + const from = path.resolve(os.tmpdir(), 'moveDirectoryRecursiveSyncFrom'); + const to = path.resolve(os.tmpdir(), 'moveDirectoryRecursiveSyncTo'); + + afterEach(async () => { + await Promise.all([fileUtils.deleteDir(from), fileUtils.deleteDir(to)]); + }); + + it('should override target files', async () => { + // Arrange + const fromFileA = new File(path.resolve(from, 'a.js'), 'original a'); + const fromFileB = new File(path.resolve(from, 'b', 'b.js'), 'original b'); + const toFileA = new File(path.resolve(to, 'a.js'), 'mutated a'); + const toFileB = new File(path.resolve(to, 'b', 'b.js'), 'mutated b'); + await writeAll(fromFileA, fromFileB, toFileA, toFileB); + + // Act + fileUtils.moveDirectoryRecursiveSync(from, to); + + // Assert + const files = await readDirRecursive(to); + assertions.expectTextFilesEqual(files, [new File(toFileA.name, fromFileA.content), new File(toFileB.name, fromFileB.content)]); + }); + + it("should create dirs that don't exist", async () => { + // Arrange + const fromFileA = new File(path.resolve(from, 'a.js'), 'original a'); + const fromFileB = new File(path.resolve(from, 'b', 'b.js'), 'original b'); + await writeAll(fromFileA, fromFileB); + + // Act + fileUtils.moveDirectoryRecursiveSync(from, to); + + // Assert + const files = await readDirRecursive(to); + assertions.expectTextFilesEqual(files, [ + new File(path.resolve(to, 'a.js'), fromFileA.content), + new File(path.resolve(to, 'b', 'b.js'), fromFileB.content), + ]); + }); + + it('should remove the from directory', async () => { + // Arrange + const fromFileA = new File(path.resolve(from, 'a.js'), 'original a'); + const fromFileB = new File(path.resolve(from, 'b', 'b.js'), 'original b'); + await writeAll(fromFileA, fromFileB); + + // Act + fileUtils.moveDirectoryRecursiveSync(from, to); + + // Assert + await expect(fsPromises.access(from)).rejected; + }); + }); + + async function readDirRecursive(dir: string): Promise { + return new Promise((res, rej) => { + nodeGlob(path.join(dir, '**/*'), { nodir: true }, (err, matches) => { + if (err) { + rej(err); + } + // eslint-disable-next-line @typescript-eslint/require-array-sort-compare + matches.sort(); + res( + Promise.all( + matches.map(async (fileName) => { + const content = await fsPromises.readFile(fileName); + return new File(path.normalize(fileName), content); + }) + ) + ); + }); + }); + } + + async function writeAll(...files: File[]): Promise { + await Promise.all( + files.map(async (file) => { + await mkdirp(path.dirname(file.name)); + await fsPromises.writeFile(file.name, file.content); + }) + ); + } +}); diff --git a/packages/core/test/integration/utils/file-utils.spec.ts b/packages/core/test/integration/utils/file-utils.spec.ts deleted file mode 100644 index 6eb0b4d243..0000000000 --- a/packages/core/test/integration/utils/file-utils.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { expect } from 'chai'; - -import * as fileUtils from '../../../src/utils/file-utils'; - -describe('fileUtils', () => { - describe('glob', () => { - it('should resolve files', () => expect(fileUtils.glob('testResources/globTestFiles/sample/**/*.js')).to.eventually.have.length(10)); - - it('should not resolve to directories', () => - expect(fileUtils.glob('testResources/globTestFiles/notResolveDirs/**/*.js')).to.eventually.have.length(1)); - }); -}); diff --git a/packages/core/test/unit/config/options-validator.spec.ts b/packages/core/test/unit/config/options-validator.spec.ts index 1138870a6c..e1f5fe040b 100644 --- a/packages/core/test/unit/config/options-validator.spec.ts +++ b/packages/core/test/unit/config/options-validator.spec.ts @@ -30,6 +30,7 @@ describe(OptionsValidator.name, () => { appendPlugins: [], checkers: [], cleanTempDir: true, + inPlace: false, clearTextReporter: { allowColor: true, logTests: true, diff --git a/packages/core/test/unit/di/build-main-injector.spec.ts b/packages/core/test/unit/di/build-main-injector.spec.ts index 2df9a252f5..24b1ce7098 100644 --- a/packages/core/test/unit/di/build-main-injector.spec.ts +++ b/packages/core/test/unit/di/build-main-injector.spec.ts @@ -13,6 +13,7 @@ import { PluginCreator, PluginLoader, coreTokens, provideLogger } from '../../.. import { buildMainInjector, CliOptionsProvider } from '../../../src/di/build-main-injector'; import * as broadcastReporterModule from '../../../src/reporters/broadcast-reporter'; import currentLogMock from '../../helpers/log-mock'; +import { UnexpectedExitHandler } from '../../../src/unexpected-exit-handler'; describe(buildMainInjector.name, () => { let pluginLoaderMock: sinon.SinonStubbedInstance; @@ -45,6 +46,10 @@ describe(buildMainInjector.name, () => { stubInjectable(broadcastReporterModule, 'default').returns(broadcastReporterMock); }); + afterEach(async () => { + await injector.dispose(); + }); + function stubInjectable(obj: T, method: keyof T) { const inject = (obj[method] as any).inject; const stub = sinon.stub(obj, method); @@ -104,4 +109,9 @@ describe(buildMainInjector.name, () => { expect(actualInjector.resolve(coreTokens.pluginCreatorReporter)).eq(pluginCreatorMock); expect(actualInjector.resolve(coreTokens.pluginCreatorChecker)).eq(pluginCreatorMock); }); + + it('should be able to supply a UnexpectedExitRegister', () => { + const actualInjector = buildMainInjector(injector); + expect(actualInjector.resolve(coreTokens.unexpectedExitRegistry)).instanceOf(UnexpectedExitHandler); + }); }); diff --git a/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts b/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts index 70d9901a62..6dcba66d32 100644 --- a/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts +++ b/packages/core/test/unit/process/2-mutant-instrumenter-executor.spec.ts @@ -52,7 +52,7 @@ describe(MutantInstrumenterExecutor.name, () => { sut = new MutantInstrumenterExecutor(injectorMock as Injector, inputFiles, testInjector.options); injectorMock.injectClass.withArgs(Instrumenter).returns(instrumenterMock); injectorMock.injectFunction.withArgs(createPreprocessor).returns(sandboxFilePreprocessorMock); - injectorMock.injectFunction.withArgs(Sandbox.create).returns(sandboxMock); + injectorMock.resolve.withArgs(coreTokens.sandbox).returns(sandboxMock); injectorMock.resolve .withArgs(coreTokens.concurrencyTokenProvider) .returns(concurrencyTokenProviderMock) @@ -77,13 +77,13 @@ describe(MutantInstrumenterExecutor.name, () => { it('should preprocess files before initializing the sandbox', async () => { await sut.execute(); expect(sandboxFilePreprocessorMock.preprocess).calledWithExactly([mutatedFile, testFile]); - expect(sandboxFilePreprocessorMock.preprocess).calledBefore(injectorMock.injectFunction); + expect(sandboxFilePreprocessorMock.preprocess).calledBefore(sandboxMock.init); }); it('should provide the mutated files to the sandbox', async () => { await sut.execute(); expect(injectorMock.provideValue).calledWithExactly(coreTokens.files, [mutatedFile, testFile]); - expect(injectorMock.provideValue.withArgs(coreTokens.files, sinon.match.any)).calledBefore(injectorMock.injectFunction); + expect(injectorMock.provideValue.withArgs(coreTokens.files, sinon.match.any)).calledBefore(sandboxMock.init); }); it('should provide checkerToken$ to the checker pool', async () => { @@ -101,7 +101,7 @@ describe(MutantInstrumenterExecutor.name, () => { it('should initialize the CheckerPool before creating the sandbox', async () => { // This is important for in-place mutation. We need to initialize the typescript checker(s) before we write mutated files to disk. await sut.execute(); - expect(checkerPoolMock.init).calledBefore(injectorMock.injectFunction.withArgs(Sandbox.create)); + expect(checkerPoolMock.init).calledBefore(injectorMock.provideClass.withArgs(coreTokens.sandbox, Sandbox)); }); it('should provide mutants in the result', async () => { @@ -111,6 +111,11 @@ describe(MutantInstrumenterExecutor.name, () => { it('should provide the sandbox in the result', async () => { await sut.execute(); - expect(injectorMock.provideValue).calledWithExactly(coreTokens.sandbox, sandboxMock); + expect(injectorMock.provideClass).calledWithExactly(coreTokens.sandbox, Sandbox); + }); + + it('should initialize the sandbox', async () => { + await sut.execute(); + expect(sandboxMock.init).calledOnce; }); }); diff --git a/packages/core/test/unit/sandbox/sandbox.spec.ts b/packages/core/test/unit/sandbox/sandbox.spec.ts index 89c44514b6..19d5276ef7 100644 --- a/packages/core/test/unit/sandbox/sandbox.spec.ts +++ b/packages/core/test/unit/sandbox/sandbox.spec.ts @@ -1,10 +1,10 @@ import path = require('path'); +import { promises as fsPromises } from 'fs'; import execa = require('execa'); import npmRunPath = require('npm-run-path'); import { expect } from 'chai'; import sinon = require('sinon'); -import * as mkdirp from 'mkdirp'; import { testInjector, tick } from '@stryker-mutator/test-helpers'; import { File } from '@stryker-mutator/api/core'; import { fileAlreadyExistsError } from '@stryker-mutator/test-helpers/src/factory'; @@ -14,72 +14,184 @@ import { Sandbox } from '../../../src/sandbox/sandbox'; import { coreTokens } from '../../../src/di'; import { TemporaryDirectory } from '../../../src/utils/temporary-directory'; import * as fileUtils from '../../../src/utils/file-utils'; +import { UnexpectedExitHandler } from '../../../src/unexpected-exit-handler'; describe(Sandbox.name, () => { let temporaryDirectoryMock: sinon.SinonStubbedInstance; let files: File[]; - let mkdirpSyncStub: sinon.SinonStub; + let mkdirpStub: sinon.SinonStub; let writeFileStub: sinon.SinonStub; let symlinkJunctionStub: sinon.SinonStub; let findNodeModulesStub: sinon.SinonStub; let execaMock: sinon.SinonStubbedInstance; + let unexpectedExitHandlerMock: sinon.SinonStubbedInstance; + let readFile: sinon.SinonStub; + let moveDirectoryRecursiveSyncStub: sinon.SinonStub; const SANDBOX_WORKING_DIR = 'sandbox-123'; + const BACKUP_DIR = 'backup-123'; beforeEach(() => { temporaryDirectoryMock = sinon.createStubInstance(TemporaryDirectory); - temporaryDirectoryMock.createRandomDirectory.returns(SANDBOX_WORKING_DIR); - mkdirpSyncStub = sinon.stub(mkdirp, 'sync'); - writeFileStub = sinon.stub(fileUtils, 'writeFile'); + temporaryDirectoryMock.createRandomDirectory.withArgs('sandbox').returns(SANDBOX_WORKING_DIR).withArgs('backup').returns(BACKUP_DIR); + mkdirpStub = sinon.stub(fileUtils, 'mkdirp'); + writeFileStub = sinon.stub(fsPromises, 'writeFile'); symlinkJunctionStub = sinon.stub(fileUtils, 'symlinkJunction'); findNodeModulesStub = sinon.stub(fileUtils, 'findNodeModules'); + moveDirectoryRecursiveSyncStub = sinon.stub(fileUtils, 'moveDirectoryRecursiveSync'); + readFile = sinon.stub(fsPromises, 'readFile'); execaMock = { command: sinon.stub(), commandSync: sinon.stub(), node: sinon.stub(), sync: sinon.stub(), }; + unexpectedExitHandlerMock = { + registerHandler: sinon.stub(), + dispose: sinon.stub(), + }; symlinkJunctionStub.resolves(); findNodeModulesStub.resolves('node_modules'); files = []; }); - function createSut(): Promise { + function createSut(): Sandbox { return testInjector.injector .provideValue(coreTokens.files, files) .provideValue(coreTokens.temporaryDirectory, temporaryDirectoryMock) .provideValue(coreTokens.execa, (execaMock as unknown) as typeof execa) - .injectFunction(Sandbox.create); + .provideValue(coreTokens.unexpectedExitRegistry, unexpectedExitHandlerMock) + .injectClass(Sandbox); } - describe('create()', () => { - it('should have created a sandbox folder', async () => { - await createSut(); - expect(temporaryDirectoryMock.createRandomDirectory).calledWith('sandbox'); - }); + describe('init()', () => { + describe('with inPlace = false', () => { + beforeEach(() => { + testInjector.options.inPlace = false; + }); - it('should copy regular input files', async () => { - const fileB = new File(path.resolve('a', 'b.txt'), 'b content'); - const fileE = new File(path.resolve('c', 'd', 'e.log'), 'e content'); - files.push(fileB); - files.push(fileE); - await createSut(); - expect(writeFileStub).calledWith(path.join(SANDBOX_WORKING_DIR, 'a', 'b.txt'), fileB.content); - expect(writeFileStub).calledWith(path.join(SANDBOX_WORKING_DIR, 'c', 'd', 'e.log'), fileE.content); - }); + it('should have created a sandbox folder', async () => { + const sut = createSut(); + await sut.init(); + expect(temporaryDirectoryMock.createRandomDirectory).calledWith('sandbox'); + }); + + it('should copy regular input files', async () => { + const fileB = new File(path.resolve('a', 'b.txt'), 'b content'); + const fileE = new File(path.resolve('c', 'd', 'e.log'), 'e content'); + files.push(fileB); + files.push(fileE); + const sut = createSut(); + await sut.init(); + expect(writeFileStub).calledWith(path.join(SANDBOX_WORKING_DIR, 'a', 'b.txt'), fileB.content); + expect(writeFileStub).calledWith(path.join(SANDBOX_WORKING_DIR, 'c', 'd', 'e.log'), fileE.content); + }); - it('should make the dir before copying the file', async () => { - files.push(new File(path.resolve('a', 'b.js'), 'b content')); - files.push(new File(path.resolve('c', 'd', 'e.js'), 'e content')); - await createSut(); - expect(mkdirpSyncStub).calledTwice; - expect(mkdirpSyncStub).calledWithExactly(path.join(SANDBOX_WORKING_DIR, 'a')); - expect(mkdirpSyncStub).calledWithExactly(path.join(SANDBOX_WORKING_DIR, 'c', 'd')); + it('should make the dir before copying the file', async () => { + files.push(new File(path.resolve('a', 'b.js'), 'b content')); + files.push(new File(path.resolve('c', 'd', 'e.js'), 'e content')); + const sut = createSut(); + await sut.init(); + expect(mkdirpStub).calledTwice; + expect(mkdirpStub).calledWithExactly(path.join(SANDBOX_WORKING_DIR, 'a')); + expect(mkdirpStub).calledWithExactly(path.join(SANDBOX_WORKING_DIR, 'c', 'd')); + }); + + it('should be able to copy a local file', async () => { + files.push(new File('localFile.txt', 'foobar')); + const sut = createSut(); + await sut.init(); + expect(writeFileStub).calledWith(path.join(SANDBOX_WORKING_DIR, 'localFile.txt'), Buffer.from('foobar')); + }); + + it('should symlink node modules in sandbox directory if exists', async () => { + const sut = createSut(); + await sut.init(); + expect(findNodeModulesStub).calledWith(process.cwd()); + expect(symlinkJunctionStub).calledWith('node_modules', path.join(SANDBOX_WORKING_DIR, 'node_modules')); + }); }); - it('should be able to copy a local file', async () => { - files.push(new File('localFile.txt', 'foobar')); - await createSut(); - expect(writeFileStub).calledWith(path.join(SANDBOX_WORKING_DIR, 'localFile.txt'), Buffer.from('foobar')); + describe('with inPlace = true', () => { + beforeEach(() => { + testInjector.options.inPlace = true; + }); + + it('should have created a backup directory', async () => { + const sut = createSut(); + await sut.init(); + expect(temporaryDirectoryMock.createRandomDirectory).calledWith('backup'); + }); + + it('should not override the current file if no changes were detected', async () => { + const fileB = new File(path.resolve('a', 'b.txt'), 'b content'); + readFile.withArgs(path.resolve('a', 'b.txt')).resolves(Buffer.from('b content')); + files.push(fileB); + const sut = createSut(); + await sut.init(); + expect(writeFileStub).not.called; + }); + + it('should override original file if changes were detected', async () => { + // Arrange + const fileName = path.resolve('a', 'b.js'); + const originalContent = Buffer.from('b content'); + const fileB = new File(fileName, 'b mutated content'); + readFile.withArgs(fileName).resolves(originalContent); + files.push(fileB); + + // Act + const sut = createSut(); + await sut.init(); + + // Assert + expect(writeFileStub).calledWith(fileB.name, fileB.content); + }); + + it('should override backup the original before overriding it', async () => { + // Arrange + const fileName = path.resolve('a', 'b.js'); + const originalContent = Buffer.from('b content'); + const fileB = new File(fileName, 'b mutated content'); + readFile.withArgs(fileName).resolves(originalContent); + files.push(fileB); + const expectedBackupDirectory = path.join(BACKUP_DIR, 'a'); + const expectedBackupFileName = path.join(expectedBackupDirectory, 'b.js'); + + // Act + const sut = createSut(); + await sut.init(); + + // Assert + expect(mkdirpStub).calledWith(expectedBackupDirectory); + expect(writeFileStub).calledWith(expectedBackupFileName, originalContent); + expect(writeFileStub.withArgs(expectedBackupFileName)).calledBefore(writeFileStub.withArgs(fileB.name)); + }); + + it('should log the backup file location', async () => { + // Arrange + const fileName = path.resolve('a', 'b.js'); + const originalContent = Buffer.from('b content'); + const fileB = new File(fileName, 'b mutated content'); + readFile.withArgs(fileName).resolves(originalContent); + files.push(fileB); + const expectedBackupFileName = path.join(BACKUP_DIR, 'a', 'b.js'); + + // Act + const sut = createSut(); + await sut.init(); + + // Assert + expect(testInjector.logger.debug).calledWith('Stored backup file at %s', expectedBackupFileName); + }); + + it('should register an unexpected exit handler', async () => { + // Act + const sut = createSut(); + await sut.init(); + + // Assert + expect(unexpectedExitHandlerMock.registerHandler).called; + }); }); it('should not open too many file handles', async () => { @@ -94,7 +206,8 @@ describe(Sandbox.name, () => { } // Act - const onGoingWork = createSut(); + const sut = createSut(); + const initPromise = sut.init(); await tick(); expect(writeFileStub).callCount(maxFileIO); fileHandles[0].task.resolve(); @@ -103,18 +216,13 @@ describe(Sandbox.name, () => { // Assert expect(writeFileStub).callCount(maxFileIO + 1); fileHandles.forEach(({ task }) => task.resolve()); - await onGoingWork; - }); - - it('should symlink node modules in sandbox directory if exists', async () => { - await createSut(); - expect(findNodeModulesStub).calledWith(process.cwd()); - expect(symlinkJunctionStub).calledWith('node_modules', path.join(SANDBOX_WORKING_DIR, 'node_modules')); + await initPromise; }); it('should not symlink node modules in sandbox directory if no node_modules exist', async () => { findNodeModulesStub.resolves(null); - await createSut(); + const sut = createSut(); + await sut.init(); expect(testInjector.logger.warn).calledWithMatch('Could not find a node_modules'); expect(testInjector.logger.warn).calledWithMatch(process.cwd()); expect(symlinkJunctionStub).not.called; @@ -123,7 +231,8 @@ describe(Sandbox.name, () => { it('should log a warning if "node_modules" already exists in the working folder', async () => { findNodeModulesStub.resolves('node_modules'); symlinkJunctionStub.rejects(fileAlreadyExistsError()); - await createSut(); + const sut = createSut(); + await sut.init(); expect(testInjector.logger.warn).calledWithMatch( normalizeWhitespaces( `Could not symlink "node_modules" in sandbox directory, it is already created in the sandbox. @@ -137,7 +246,8 @@ describe(Sandbox.name, () => { findNodeModulesStub.resolves('basePath/node_modules'); const error = new Error('unknown'); symlinkJunctionStub.rejects(error); - await createSut(); + const sut = createSut(); + await sut.init(); expect(testInjector.logger.warn).calledWithMatch( normalizeWhitespaces('Unexpected error while trying to symlink "basePath/node_modules" in sandbox directory.'), error @@ -146,44 +256,72 @@ describe(Sandbox.name, () => { it('should symlink node modules in sandbox directory if `symlinkNodeModules` is `false`', async () => { testInjector.options.symlinkNodeModules = false; - await createSut(); + const sut = createSut(); + await sut.init(); expect(symlinkJunctionStub).not.called; expect(findNodeModulesStub).not.called; }); it('should execute the buildCommand in the sandbox', async () => { testInjector.options.buildCommand = 'npm run build'; - await createSut(); + const sut = createSut(); + await sut.init(); expect(execaMock.command).calledWith('npm run build', { cwd: SANDBOX_WORKING_DIR, env: npmRunPath.env() }); - expect(testInjector.logger.info).calledWith('Running build command "%s" in the sandbox at "%s".', 'npm run build', SANDBOX_WORKING_DIR); + expect(testInjector.logger.info).calledWith('Running build command "%s" in "%s".', 'npm run build', SANDBOX_WORKING_DIR); }); it('should not execute a build command when non is configured', async () => { testInjector.options.buildCommand = undefined; - await createSut(); + const sut = createSut(); + await sut.init(); expect(execaMock.command).not.called; }); it('should execute the buildCommand before the node_modules are symlinked', async () => { // It is important to first run the buildCommand, otherwise the build dependencies are not correctly resolved testInjector.options.buildCommand = 'npm run build'; - await createSut(); + const sut = createSut(); + await sut.init(); expect(execaMock.command).calledBefore(symlinkJunctionStub); }); }); - describe('get sandboxFileNames()', () => { - it('should retrieve all files', async () => { - files.push(new File('a.js', '')); - files.push(new File(path.resolve('b', 'c', 'e.js'), '')); - const sut = await createSut(); - expect(sut.sandboxFileNames).deep.eq([path.join(SANDBOX_WORKING_DIR, 'a.js'), path.join(SANDBOX_WORKING_DIR, 'b', 'c', 'e.js')]); + describe('dispose', () => { + it("shouldn't do anything when inPlace = false", () => { + const sut = createSut(); + sut.dispose(); + expect(moveDirectoryRecursiveSyncStub).not.called; + }); + + it('should recover from the backup dir synchronously if inPlace = true', () => { + testInjector.options.inPlace = true; + const sut = createSut(); + sut.dispose(); + expect(moveDirectoryRecursiveSyncStub).calledWith(BACKUP_DIR, process.cwd()); + }); + + it('should recover from the backup dir if stryker exits unexpectedly while inPlace = true', () => { + testInjector.options.inPlace = true; + const errorStub = sinon.stub(console, 'error'); + createSut(); + unexpectedExitHandlerMock.registerHandler.callArg(0); + expect(moveDirectoryRecursiveSyncStub).calledWith(BACKUP_DIR, process.cwd()); + expect(errorStub).calledWith(`Detecting unexpected exit, recovering original files from ${BACKUP_DIR}`); }); }); + describe('workingDirectory', () => { - it('should retrieve the sandbox directory', async () => { - const sut = await createSut(); + it('should retrieve the sandbox directory when inPlace = false', async () => { + const sut = createSut(); + await sut.init(); expect(sut.workingDirectory).eq(SANDBOX_WORKING_DIR); }); + + it('should retrieve the cwd directory when inPlace = true', async () => { + testInjector.options.inPlace = true; + const sut = createSut(); + await sut.init(); + expect(sut.workingDirectory).eq(process.cwd()); + }); }); }); diff --git a/packages/core/test/unit/sandbox/ts-config-preprocessor.it.spec.ts b/packages/core/test/unit/sandbox/ts-config-preprocessor.it.spec.ts index df116235ed..c5fca58ead 100644 --- a/packages/core/test/unit/sandbox/ts-config-preprocessor.it.spec.ts +++ b/packages/core/test/unit/sandbox/ts-config-preprocessor.it.spec.ts @@ -2,7 +2,7 @@ import path = require('path'); import { expect } from 'chai'; import { File } from '@stryker-mutator/api/core'; -import { testInjector } from '@stryker-mutator/test-helpers'; +import { assertions, testInjector } from '@stryker-mutator/test-helpers'; import { TSConfigPreprocessor } from '../../../src/sandbox/ts-config-preprocessor'; @@ -24,19 +24,26 @@ describe(TSConfigPreprocessor.name, () => { it('should ignore missing "extends"', async () => { files.push(tsconfigFile('tsconfig.json', { references: [{ path: './tsconfig.src.json' }] })); const output = await sut.preprocess(files); - expect(output).deep.eq(files); + assertions.expectTextFilesEqual(output, files); }); it('should ignore missing "references"', async () => { files.push(tsconfigFile('tsconfig.json', { extends: './tsconfig.settings.json' })); const output = await sut.preprocess(files); - expect(output).deep.eq(files); + assertions.expectTextFilesEqual(output, files); }); it('should rewrite "extends" if it falls outside of sandbox', async () => { files.push(tsconfigFile('tsconfig.json', { extends: '../tsconfig.settings.json' })); const output = await sut.preprocess(files); - expect(output).deep.eq([tsconfigFile('tsconfig.json', { extends: '../../../tsconfig.settings.json' })]); + assertions.expectTextFilesEqual(output, [tsconfigFile('tsconfig.json', { extends: '../../../tsconfig.settings.json' })]); + }); + + it('should not do anything when inPlace = true', async () => { + testInjector.options.inPlace = true; + files.push(tsconfigFile('tsconfig.json', { extends: '../tsconfig.settings.json' })); + const output = await sut.preprocess(files); + assertions.expectTextFilesEqual(output, files); }); it('should support comments and other settings', async () => { @@ -53,13 +60,15 @@ describe(TSConfigPreprocessor.name, () => { ) ); const output = await sut.preprocess(files); - expect(output).deep.eq([tsconfigFile('tsconfig.json', { extends: '../../../tsconfig.settings.json', compilerOptions: { target: 'es5' } })]); + assertions.expectTextFilesEqual(output, [ + tsconfigFile('tsconfig.json', { extends: '../../../tsconfig.settings.json', compilerOptions: { target: 'es5' } }), + ]); }); it('should rewrite "references" if it falls outside of sandbox', async () => { files.push(tsconfigFile('tsconfig.json', { references: [{ path: '../model' }] })); const output = await sut.preprocess(files); - expect(output).deep.eq([tsconfigFile('tsconfig.json', { references: [{ path: '../../../model/tsconfig.json' }] })]); + assertions.expectTextFilesEqual(output, [tsconfigFile('tsconfig.json', { references: [{ path: '../../../model/tsconfig.json' }] })]); }); it('should rewrite referenced tsconfig files that are also located in the sandbox', async () => { @@ -67,7 +76,7 @@ describe(TSConfigPreprocessor.name, () => { files.push(tsconfigFile('tsconfig.settings.json', { extends: '../../tsconfig.root-settings.json' })); files.push(tsconfigFile('src/tsconfig.json', { references: [{ path: '../../model' }] })); const output = await sut.preprocess(files); - expect(output).deep.eq([ + assertions.expectTextFilesEqual(output, [ tsconfigFile('tsconfig.json', { extends: './tsconfig.settings.json', references: [{ path: './src' }] }), tsconfigFile('tsconfig.settings.json', { extends: '../../../../tsconfig.root-settings.json' }), tsconfigFile('src/tsconfig.json', { references: [{ path: '../../../../model/tsconfig.json' }] }), diff --git a/packages/core/test/unit/stryker-cli.spec.ts b/packages/core/test/unit/stryker-cli.spec.ts index af7e300ed7..ade7c361f7 100644 --- a/packages/core/test/unit/stryker-cli.spec.ts +++ b/packages/core/test/unit/stryker-cli.spec.ts @@ -42,6 +42,7 @@ describe(StrykerCli.name, () => { [['--testRunner', 'foo-running'], { testRunner: 'foo-running' }], [['--testRunnerNodeArgs', '--inspect=1337 --gc'], { testRunnerNodeArgs: ['--inspect=1337', '--gc'] }], [['--coverageAnalysis', 'all'], { coverageAnalysis: 'all' }], + [['--inPlace'], { inPlace: true }], [['--concurrency', '5'], { concurrency: 5 }], [['--cleanTempDir', 'false'], { cleanTempDir: false }], [['-c', '6'], { concurrency: 6 }], @@ -100,14 +101,6 @@ describe(StrykerCli.name, () => { runMutationTestingStub.resolves(); actRun(args); expect(runMutationTestingStub).called; - const actualOptions: PartialStrykerOptions = runMutationTestingStub.getCall(0).args[0]; - for (const option in actualOptions) { - // Unfortunately, commander leaves all unspecified options as `undefined` on the object. - // This is not a problem for stryker, so let's clean them for this test. - if (actualOptions[option] === undefined) { - delete actualOptions[option]; - } - } expect(runMutationTestingStub).calledWith(expectedOptions); } }); diff --git a/packages/core/test/unit/unexpected-exit-handler.spec.ts b/packages/core/test/unit/unexpected-exit-handler.spec.ts new file mode 100644 index 0000000000..dc1cf08097 --- /dev/null +++ b/packages/core/test/unit/unexpected-exit-handler.spec.ts @@ -0,0 +1,72 @@ +import { EventEmitter } from 'events'; + +import { testInjector } from '@stryker-mutator/test-helpers'; +import sinon = require('sinon'); +import { expect } from 'chai'; + +import { coreTokens } from '../../src/di'; +import { UnexpectedExitHandler } from '../../src/unexpected-exit-handler'; + +class ProcessMock extends EventEmitter { + public exit = sinon.stub(); +} +const signals = Object.freeze(['SIGABRT', 'SIGINT', 'SIGHUP', 'SIGTERM']); + +describe(UnexpectedExitHandler.name, () => { + let processMock: ProcessMock; + + beforeEach(() => { + processMock = new ProcessMock(); + }); + + function createSut() { + return testInjector.injector + .provideValue(coreTokens.process, (processMock as unknown) as Pick) + .injectClass(UnexpectedExitHandler); + } + + describe('constructor', () => { + it('should register an exit handler', () => { + createSut(); + expect(processMock.listenerCount('exit')).eq(1); + }); + + signals.forEach((signal) => { + it(`should register a "${signal}" signal handler`, () => { + createSut(); + expect(processMock.listenerCount(signal)).eq(1); + }); + }); + }); + + describe('dispose', () => { + it('should remove the "exit" handler', () => { + createSut().dispose(); + expect(processMock.listenerCount('exit')).eq(0); + }); + signals.forEach((signal) => { + it(`should remove the "${signal}" signal handler`, () => { + createSut().dispose(); + expect(processMock.listenerCount(signal)).eq(0); + }); + }); + }); + + signals.forEach((signal) => { + it(`should call process.exit on "${signal}" signal`, () => { + createSut(); + processMock.emit(signal, signal, 4); + expect(processMock.exit).calledWith(132); + }); + }); + + describe(UnexpectedExitHandler.prototype.registerHandler.name, () => { + it('should call the provided handler on exit', () => { + const exitHandler = sinon.stub(); + const sut = createSut(); + sut.registerHandler(exitHandler); + processMock.emit('exit'); + expect(exitHandler).called; + }); + }); +}); diff --git a/packages/core/test/unit/utils/file-utils.spec.ts b/packages/core/test/unit/utils/file-utils.spec.ts index 2d1fa259c0..b73c70ee54 100644 --- a/packages/core/test/unit/utils/file-utils.spec.ts +++ b/packages/core/test/unit/utils/file-utils.spec.ts @@ -15,13 +15,6 @@ describe('fileUtils', () => { statStub = sinon.stub(fs.promises, 'stat'); }); - describe('writeFile', () => { - it('should call fs.writeFile', () => { - fileUtils.writeFile('filename', 'data'); - expect(fs.promises.writeFile).calledWith('filename', 'data', 'utf8'); - }); - }); - describe('symlinkJunction', () => { it('should call fs.symlink', async () => { await fileUtils.symlinkJunction('a', 'b'); diff --git a/packages/core/test/unit/utils/temporary-directory.spec.ts b/packages/core/test/unit/utils/temporary-directory.spec.ts index 73cdfd04fe..8fa1acfb3e 100644 --- a/packages/core/test/unit/utils/temporary-directory.spec.ts +++ b/packages/core/test/unit/utils/temporary-directory.spec.ts @@ -10,6 +10,7 @@ import * as sinon from 'sinon'; import { StrykerOptions } from '@stryker-mutator/api/core'; import * as fileUtils from '../../../src/utils/file-utils'; +import * as objectUtils from '../../../src/utils/object-utils'; import { TemporaryDirectory } from '../../../src/utils/temporary-directory'; describe(TemporaryDirectory.name, () => { @@ -25,7 +26,7 @@ describe(TemporaryDirectory.name, () => { sut = createSut(); - randomStub = sinon.stub(sut, 'random'); + randomStub = sinon.stub(objectUtils, 'random'); randomStub.returns('rand'); }); diff --git a/packages/core/tsconfig.src.json b/packages/core/tsconfig.src.json index 4817489634..aaae7cde8d 100644 --- a/packages/core/tsconfig.src.json +++ b/packages/core/tsconfig.src.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": ".", "noImplicitThis": true, + "strictBindCallApply": true, "resolveJsonModule": true, "types": [] // Exclude global mocha functions for the sources }, diff --git a/packages/karma-runner/src/karma-plugins/test-hooks-middleware-21f23d35-a4c9-4b01-aeff-da9c99c3ffc0.ts b/packages/karma-runner/src/karma-plugins/test-hooks-middleware-21f23d35-a4c9-4b01-aeff-da9c99c3ffc0.ts new file mode 100644 index 0000000000..486f81e385 --- /dev/null +++ b/packages/karma-runner/src/karma-plugins/test-hooks-middleware-21f23d35-a4c9-4b01-aeff-da9c99c3ffc0.ts @@ -0,0 +1 @@ +// Empty, replaced at runtime diff --git a/packages/karma-runner/src/karma-plugins/test-hooks-middleware.ts b/packages/karma-runner/src/karma-plugins/test-hooks-middleware.ts index 88584289bb..f0d62e2088 100644 --- a/packages/karma-runner/src/karma-plugins/test-hooks-middleware.ts +++ b/packages/karma-runner/src/karma-plugins/test-hooks-middleware.ts @@ -6,7 +6,8 @@ import { CoverageAnalysis, INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/c import { MutantRunOptions } from '@stryker-mutator/api/test-runner'; import { escapeRegExpLiteral } from '@stryker-mutator/util'; -export const TEST_HOOKS_FILE_NAME = __filename; +export const TEST_HOOKS_FILE_NAME = require.resolve('./test-hooks-middleware-21f23d35-a4c9-4b01-aeff-da9c99c3ffc0'); +const TEST_HOOKS_FILE_BASE_NAME = path.basename(TEST_HOOKS_FILE_NAME); const SUPPORTED_FRAMEWORKS = Object.freeze(['mocha', 'jasmine'] as const); @@ -102,7 +103,7 @@ export default class TestHooksMiddleware { public handler: RequestHandler = (request, response, next) => { const pathName = url.parse(request.url).pathname; - if (pathName && path.normalize(pathName).endsWith(TEST_HOOKS_FILE_NAME)) { + if (pathName?.endsWith(TEST_HOOKS_FILE_BASE_NAME)) { response.writeHead(200, { 'Cache-Control': 'no-cache', 'Content-Type': 'application/javascript', diff --git a/packages/karma-runner/test/unit/karma-plugins/test-hooks-middleware.spec.ts b/packages/karma-runner/test/unit/karma-plugins/test-hooks-middleware.spec.ts index 18e0f12136..ac20fea0e9 100644 --- a/packages/karma-runner/test/unit/karma-plugins/test-hooks-middleware.spec.ts +++ b/packages/karma-runner/test/unit/karma-plugins/test-hooks-middleware.spec.ts @@ -1,7 +1,8 @@ +import path = require('path'); + import { expect } from 'chai'; import { factory } from '@stryker-mutator/test-helpers'; import { Request, NextFunction, Response } from 'express'; - import sinon = require('sinon'); import TestHooksMiddleware, { TEST_HOOKS_FILE_NAME } from '../../../src/karma-plugins/test-hooks-middleware'; @@ -127,7 +128,7 @@ describe(TestHooksMiddleware.name, () => { it('should pass serve "currentTestHooks" when called with the correct url', () => { sut.currentTestHooks = 'foo test hooks'; - request.url = `/absolute${TEST_HOOKS_FILE_NAME}?foo=bar`; + request.url = `/absolute${path.basename(TEST_HOOKS_FILE_NAME)}?foo=bar`; sut.handler(request, response, next); expect(next).not.called; expect(response.writeHead).calledWith(200, {