diff --git a/packages/api/schema/stryker-core.json b/packages/api/schema/stryker-core.json
index d2ce869742..45b719e40b 100644
--- a/packages/api/schema/stryker-core.json
+++ b/packages/api/schema/stryker-core.json
@@ -400,6 +400,14 @@
"type": "string",
"default": "command"
},
+ "testRunnerNodeArgs": {
+ "description": "Configure arguments to be passed as exec arguments to the test runner child process. For example, running Stryker with `--timeoutMS 9999999 --concurrency 1 --testRunnerNodeArgs --inspect-brk` will allow you to debug the test runner child process. See `execArgv` of [`child_process.fork`](https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options)",
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "string"
+ }
+ },
"thresholds": {
"description": "Specify the thresholds for mutation score.",
"$ref": "#/definitions/mutationScoreThresholds",
diff --git a/packages/core/README.md b/packages/core/README.md
index 24049ef39d..7a91a85c8f 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -395,9 +395,9 @@ _Note: Use of "testFramework" is no longer needed. You can remove it from your c
### `testRunner` [`string`]
-Default: `'command'`
-Command line: `--testRunner karma`
-Config file: `testRunner: 'karma'`
+Default: `'command'`
+Command line: `--testRunner karma`
+Config file: `testRunner: 'karma'`
With `testRunner` you specify the test runner that Stryker uses to run your tests. The default value is `command`. The command runner runs a configurable bash/cmd command and bases the result on the exit code of that program (0 for success, otherwise failed). You can configure this command via the config file using the `commandRunner: { command: 'npm run mocha' }`. It uses `npm test` as the command by default.
@@ -407,6 +407,15 @@ If possible, you should try to use one of the test runner plugins that hook into
For example: install and use the `stryker-karma-runner` to use `karma` as a test runner.
See the [list of plugins](https://stryker-mutator.io/plugins.html) for an up-to-date list of supported test runners and plugins.
+
+### `testRunnerNodeArgs` [`string[]`]
+
+Default: `[]`
+Command line: `--testRunnerNodeArgs "--inspect-brk --cpu-prof"`
+Config file: `testRunnerNodeArgs: ['--inspect-brk', '--cpu-prof']`
+
+Configure arguments to be passed as exec arguments to the test runner child process. For example, running Stryker with `--timeoutMS 9999999 --concurrency 1 --testRunnerNodeArgs "--inspect-brk"` will allow you to debug the test runner child process. See `execArgv` of [`child_process.fork`](https://nodejs.org/api/child_process.html#child_process_child_process_fork_modulepath_args_options).
+
### `thresholds` [`object`]
diff --git a/packages/core/src/checker/checker-facade.ts b/packages/core/src/checker/checker-facade.ts
index 07d477f6b8..8132d99c41 100644
--- a/packages/core/src/checker/checker-facade.ts
+++ b/packages/core/src/checker/checker-facade.ts
@@ -22,7 +22,15 @@ export class CheckerFacade implements Checker, Disposable, Worker {
constructor(options: StrykerOptions, loggingContext: LoggingClientContext) {
if (options.checkers.length) {
- this.childProcess = ChildProcessProxy.create(require.resolve('./checker-worker'), loggingContext, options, {}, process.cwd(), CheckerWorker);
+ this.childProcess = ChildProcessProxy.create(
+ require.resolve('./checker-worker'),
+ loggingContext,
+ options,
+ {},
+ process.cwd(),
+ CheckerWorker,
+ []
+ );
}
}
diff --git a/packages/core/src/child-proxy/child-process-proxy.ts b/packages/core/src/child-proxy/child-process-proxy.ts
index bd217a5624..3f40c85f60 100644
--- a/packages/core/src/child-proxy/child-process-proxy.ts
+++ b/packages/core/src/child-proxy/child-process-proxy.ts
@@ -46,11 +46,12 @@ export default class ChildProcessProxy implements Disposable {
loggingContext: LoggingClientContext,
options: StrykerOptions,
additionalInjectableValues: unknown,
- workingDirectory: string
+ workingDirectory: string,
+ execArgv: string[]
) {
- this.worker = fork(require.resolve('./child-process-proxy-worker'), [autoStart], { silent: true, execArgv: [] });
+ this.worker = fork(require.resolve('./child-process-proxy-worker'), [autoStart], { silent: true, execArgv });
this.initTask = new Task();
- this.log.debug('Starting %s in child process %s', requirePath, this.worker.pid);
+ this.log.debug('Started %s in child process %s%s', requireName, this.worker.pid, execArgv.length ? ` (using args ${execArgv.join(' ')})` : '');
this.send({
additionalInjectableValues,
kind: WorkerMessageKind.Init,
@@ -77,9 +78,10 @@ export default class ChildProcessProxy implements Disposable {
options: StrykerOptions,
additionalInjectableValues: TAdditionalContext,
workingDirectory: string,
- injectableClass: InjectableClass
+ injectableClass: InjectableClass,
+ execArgv: string[]
): ChildProcessProxy {
- return new ChildProcessProxy(requirePath, injectableClass.name, loggingContext, options, additionalInjectableValues, workingDirectory);
+ return new ChildProcessProxy(requirePath, injectableClass.name, loggingContext, options, additionalInjectableValues, workingDirectory, execArgv);
}
private send(message: WorkerMessage) {
@@ -170,6 +172,10 @@ export default class ChildProcessProxy implements Disposable {
return this.stdoutBuilder.toString();
}
+ public get stderr() {
+ return this.stderrBuilder.toString();
+ }
+
private reportError(error: Error) {
this.workerTasks.filter((task) => !task.isCompleted).forEach((task) => task.reject(error));
}
diff --git a/packages/core/src/config/options-validator.ts b/packages/core/src/config/options-validator.ts
index bf76d0305e..071721ec15 100644
--- a/packages/core/src/config/options-validator.ts
+++ b/packages/core/src/config/options-validator.ts
@@ -11,6 +11,8 @@ import { coreTokens } from '../di';
import { ConfigError } from '../errors';
import { isWarningEnabled } from '../utils/object-utils';
+import CommandTestRunner from '../test-runner/command-test-runner';
+
import { describeErrors } from './validation-errors';
const ajv = new Ajv({ useDefaults: true, allErrors: true, jsonPointers: false, verbose: true, missingRefs: 'ignore', logger: false });
@@ -79,6 +81,11 @@ export class OptionsValidator {
options.concurrency = options.maxConcurrentTestRunners;
}
}
+ if (CommandTestRunner.is(options.testRunner) && options.testRunnerNodeArgs.length) {
+ this.log.warn(
+ 'Using "testRunnerNodeArgs" together with the "command" test runner is not supported, these arguments will be ignored. You can add your custom arguments by setting the "commandRunner.command" option.'
+ );
+ }
additionalErrors.forEach((error) => this.log.error(error));
this.throwErrorIfNeeded(additionalErrors);
}
diff --git a/packages/core/src/stryker-cli.ts b/packages/core/src/stryker-cli.ts
index 0c564b7310..49a93ae7df 100644
--- a/packages/core/src/stryker-cli.ts
+++ b/packages/core/src/stryker-cli.ts
@@ -20,8 +20,10 @@ function deepOption(object: { [K in T]?: R }, key: T) {
};
}
-function list(val: string) {
- return val.split(',');
+const list = createSplitter(',');
+
+function createSplitter(sep: string) {
+ return (val: string) => val.split(sep);
}
function parseBoolean(val: string) {
@@ -77,6 +79,11 @@ export default class StrykerCli {
`The coverage analysis strategy you want to use. Default value: "${defaultValues.coverageAnalysis}"`
)
.option('--testRunner ', 'The name of the test runner you want to use')
+ .option(
+ '--testRunnerNodeArgs ',
+ 'A comma separated list of node args to be passed to test runner child processes.',
+ createSplitter(' ')
+ )
.option('--reporters ', 'A comma separated list of the names of the reporter(s) you want to use', list)
.option('--plugins ', 'A list of plugins you want stryker to load (`require`).', list)
.option(
diff --git a/packages/core/src/test-runner/child-process-test-runner-decorator.ts b/packages/core/src/test-runner/child-process-test-runner-decorator.ts
index 9e25d572d6..70adfe2978 100644
--- a/packages/core/src/test-runner/child-process-test-runner-decorator.ts
+++ b/packages/core/src/test-runner/child-process-test-runner-decorator.ts
@@ -23,7 +23,8 @@ export default class ChildProcessTestRunnerDecorator implements TestRunner {
options,
{},
sandboxWorkingDirectory,
- ChildProcessTestRunnerWorker
+ ChildProcessTestRunnerWorker,
+ options.testRunnerNodeArgs
);
}
diff --git a/packages/core/test/integration/child-proxy/child-process-proxy.it.spec.ts b/packages/core/test/integration/child-proxy/child-process-proxy.it.spec.ts
index b31631d958..4e0c195a58 100644
--- a/packages/core/test/integration/child-proxy/child-process-proxy.it.spec.ts
+++ b/packages/core/test/integration/child-proxy/child-process-proxy.it.spec.ts
@@ -30,7 +30,9 @@ describe(ChildProcessProxy.name, () => {
const port = await loggingServer.listen();
const options = testInjector.injector.resolve(commonTokens.options);
log = currentLogMock();
- sut = ChildProcessProxy.create(require.resolve('./echo'), { port, level: LogLevel.Debug }, options, { name: echoName }, workingDir, Echo);
+ sut = ChildProcessProxy.create(require.resolve('./echo'), { port, level: LogLevel.Debug }, options, { name: echoName }, workingDir, Echo, [
+ '--no-warnings', // test if node args are forwarded with this setting, see https://nodejs.org/api/cli.html#cli_no_warnings
+ ]);
});
afterEach(async () => {
@@ -68,6 +70,11 @@ describe(ChildProcessProxy.name, () => {
expect(actual.name).eq('foobar.txt');
});
+ it('should use `execArgv` to start the child process', async () => {
+ await sut.proxy.warning();
+ expect(sut.stderr).not.includes('Foo warning');
+ });
+
it('should be able to receive a promise rejection', async () => {
await expect(sut.proxy.reject('Foobar error')).rejectedWith('Foobar error');
});
diff --git a/packages/core/test/integration/child-proxy/echo.ts b/packages/core/test/integration/child-proxy/echo.ts
index 9a9fa5e418..448db60af4 100644
--- a/packages/core/test/integration/child-proxy/echo.ts
+++ b/packages/core/test/integration/child-proxy/echo.ts
@@ -35,6 +35,10 @@ export class Echo {
return new File('foobar.txt', 'hello foobar');
}
+ public warning() {
+ process.emitWarning('Foo warning');
+ }
+
public cwd() {
return process.cwd();
}
diff --git a/packages/core/test/unit/child-proxy/child-process-proxy.spec.ts b/packages/core/test/unit/child-proxy/child-process-proxy.spec.ts
index 27022745d0..5eeec4bffa 100644
--- a/packages/core/test/unit/child-proxy/child-process-proxy.spec.ts
+++ b/packages/core/test/unit/child-proxy/child-process-proxy.spec.ts
@@ -94,6 +94,17 @@ describe(ChildProcessProxy.name, () => {
expect(childProcessMock.send).calledWith(serialize(expectedMessage));
});
+ it('should log the exec arguments and require name', () => {
+ // Act
+ createSut({
+ loggingContext: LOGGING_CONTEXT,
+ execArgv: ['--cpu-prof', '--inspect'],
+ });
+
+ // Assert
+ expect(logMock.debug).calledWith('Started %s in child process %s%s', 'HelloClass', childProcessMock.pid, ' (using args --cpu-prof --inspect)');
+ });
+
it('should listen to worker process', () => {
createSut();
expect(childProcessMock.listeners('message')).lengthOf(1);
@@ -103,6 +114,11 @@ describe(ChildProcessProxy.name, () => {
createSut();
expect(childProcessMock.listeners('close')).lengthOf(1);
});
+
+ it('should set `execArgv`', () => {
+ createSut({ execArgv: ['--inspect-brk'] });
+ expect(forkStub).calledWithMatch(sinon.match.string, sinon.match.array, sinon.match({ execArgv: ['--inspect-brk'] }));
+ });
});
describe('on close', () => {
@@ -247,6 +263,7 @@ function createSut(
options?: Partial;
workingDir?: string;
name?: string;
+ execArgv?: string[];
} = {}
): ChildProcessProxy {
return ChildProcessProxy.create(
@@ -255,6 +272,7 @@ function createSut(
factory.strykerOptions(overrides.options),
{ name: overrides.name || 'someArg' },
overrides.workingDir || 'workingDir',
- HelloClass
+ HelloClass,
+ overrides.execArgv ?? []
);
}
diff --git a/packages/core/test/unit/config/options-validator.spec.ts b/packages/core/test/unit/config/options-validator.spec.ts
index 00d9d0c7b4..a81ed39b4b 100644
--- a/packages/core/test/unit/config/options-validator.spec.ts
+++ b/packages/core/test/unit/config/options-validator.spec.ts
@@ -33,7 +33,8 @@ describe(OptionsValidator.name, () => {
});
it('should be invalid with thresholds.high null', () => {
- (testInjector.options.thresholds.high as any) = null;
+ // @ts-expect-error invalid setting
+ testInjector.options.thresholds.high = null;
actValidationErrors('Config option "thresholds.high" has the wrong type. It should be a number, but was a null.');
});
@@ -45,7 +46,8 @@ describe(OptionsValidator.name, () => {
});
it('should be invalid with invalid logLevel', () => {
- testInjector.options.logLevel = 'thisTestPasses' as any;
+ // @ts-expect-error invalid setting
+ testInjector.options.logLevel = 'thisTestPasses';
actValidationErrors(
'Config option "logLevel" should be one of the allowed values ("off", "fatal", "error", "warn", "info", "debug", "trace"), but was "thisTestPasses".'
);
@@ -97,12 +99,14 @@ describe(OptionsValidator.name, () => {
describe('mutator', () => {
it('should be invalid with non-string mutator', () => {
- (testInjector.options.mutator as any) = 1;
+ // @ts-expect-error invalid setting
+ testInjector.options.mutator = 1;
actValidationErrors('Config option "mutator" has the wrong type. It should be a object, but was a number.');
});
it('should report a deprecation warning for "mutator.name"', () => {
- (testInjector.options.mutator as any) = {
+ testInjector.options.mutator = {
+ // @ts-expect-error invalid setting
name: 'javascript',
};
sut.validate(testInjector.options);
@@ -112,7 +116,8 @@ describe(OptionsValidator.name, () => {
});
it('should report a deprecation warning for mutator as a string', () => {
- (testInjector.options.mutator as any) = 'javascript';
+ // @ts-expect-error invalid setting
+ testInjector.options.mutator = 'javascript';
sut.validate(testInjector.options);
expect(testInjector.logger.warn).calledWith(
'DEPRECATED. Use of "mutator" as string is no longer needed. You can remove it from your configuration. Stryker now supports mutating of JavaScript and friend files out of the box.'
@@ -122,7 +127,7 @@ describe(OptionsValidator.name, () => {
describe('testFramework', () => {
it('should report a deprecation warning', () => {
- (testInjector.options as any).testFramework = '';
+ testInjector.options.testFramework = '';
sut.validate(testInjector.options);
expect(testInjector.logger.warn).calledWith(
'DEPRECATED. Use of "testFramework" is no longer needed. You can remove it from your configuration. Your test runner plugin now handles its own test framework integration.'
@@ -192,9 +197,18 @@ describe(OptionsValidator.name, () => {
actValidationErrors('Config option "maxTestRunnerReuse" has the wrong type. It should be a number, but was a string.');
});
+ it('should warn when testRunnerNodeArgs are combined with the "command" test runner', () => {
+ testInjector.options.testRunnerNodeArgs = ['--inspect-brk'];
+ testInjector.options.testRunner = 'command';
+ sut.validate(testInjector.options);
+ expect(testInjector.logger.warn).calledWith(
+ 'Using "testRunnerNodeArgs" together with the "command" test runner is not supported, these arguments will be ignored. You can add your custom arguments by setting the "commandRunner.command" option.'
+ );
+ });
+
describe('transpilers', () => {
it('should report a deprecation warning', () => {
- (testInjector.options.transpilers as any) = ['stryker-jest'];
+ testInjector.options.transpilers = ['stryker-jest'];
sut.validate(testInjector.options);
expect(testInjector.logger.warn).calledWith(
'DEPRECATED. Support for "transpilers" is removed. You can now configure your own "buildCommand". For example, npm run build.'
diff --git a/packages/core/test/unit/stryker-cli.spec.ts b/packages/core/test/unit/stryker-cli.spec.ts
index 8ab59ca7c6..af7e300ed7 100644
--- a/packages/core/test/unit/stryker-cli.spec.ts
+++ b/packages/core/test/unit/stryker-cli.spec.ts
@@ -40,6 +40,7 @@ describe(StrykerCli.name, () => {
[['--maxConcurrentTestRunners', '42'], { maxConcurrentTestRunners: 42 }],
[['--tempDirName', 'foo-tmp'], { tempDirName: 'foo-tmp' }],
[['--testRunner', 'foo-running'], { testRunner: 'foo-running' }],
+ [['--testRunnerNodeArgs', '--inspect=1337 --gc'], { testRunnerNodeArgs: ['--inspect=1337', '--gc'] }],
[['--coverageAnalysis', 'all'], { coverageAnalysis: 'all' }],
[['--concurrency', '5'], { concurrency: 5 }],
[['--cleanTempDir', 'false'], { cleanTempDir: false }],
diff --git a/packages/core/test/unit/test-runner/child-process-test-runner-decorator.spec.ts b/packages/core/test/unit/test-runner/child-process-test-runner-decorator.spec.ts
index 8bd4c347be..3a396d4c3a 100644
--- a/packages/core/test/unit/test-runner/child-process-test-runner-decorator.spec.ts
+++ b/packages/core/test/unit/test-runner/child-process-test-runner-decorator.spec.ts
@@ -15,7 +15,6 @@ import ChildProcessTestRunnerDecorator from '../../../src/test-runner/child-proc
import { ChildProcessTestRunnerWorker } from '../../../src/test-runner/child-process-test-runner-worker';
describe(ChildProcessTestRunnerDecorator.name, () => {
- let sut: ChildProcessTestRunnerDecorator;
let options: StrykerOptions;
let childProcessProxyMock: {
proxy: sinon.SinonStubbedInstance>;
@@ -37,26 +36,34 @@ describe(ChildProcessTestRunnerDecorator.name, () => {
plugins: ['foo-plugin', 'bar-plugin'],
});
loggingContext = { port: 4200, level: LogLevel.Fatal };
- sut = new ChildProcessTestRunnerDecorator(options, 'a working directory', loggingContext);
});
+ function createSut(): ChildProcessTestRunnerDecorator {
+ return new ChildProcessTestRunnerDecorator(options, 'a working directory', loggingContext);
+ }
+
it('should create the child process proxy', () => {
- expect(childProcessProxyCreateStub).calledWith(
+ options.testRunnerNodeArgs = ['--inspect', '--no-warnings'];
+ createSut();
+ expect(childProcessProxyCreateStub).calledWithExactly(
require.resolve('../../../src/test-runner/child-process-test-runner-worker.js'),
loggingContext,
options,
{},
'a working directory',
- ChildProcessTestRunnerWorker
+ ChildProcessTestRunnerWorker,
+ ['--inspect', '--no-warnings']
);
});
it('should forward `init` calls', () => {
+ const sut = createSut();
childProcessProxyMock.proxy.init.resolves(42);
return expect(sut.init()).eventually.eq(42);
});
it('should forward `dryRun` calls', async () => {
+ const sut = createSut();
const expectedResult = factory.completeDryRunResult({ mutantCoverage: factory.mutantCoverage() });
childProcessProxyMock.proxy.dryRun.resolves(expectedResult);
const runOptions = factory.dryRunOptions({
@@ -68,6 +75,7 @@ describe(ChildProcessTestRunnerDecorator.name, () => {
});
it('should forward `mutantRun` calls', async () => {
+ const sut = createSut();
const expectedResult = factory.survivedMutantRunResult();
childProcessProxyMock.proxy.mutantRun.resolves(expectedResult);
const runOptions = factory.mutantRunOptions({
@@ -80,18 +88,21 @@ describe(ChildProcessTestRunnerDecorator.name, () => {
describe('dispose', () => {
it('should dispose the test runner before disposing the child process itself on `dispose`', async () => {
+ const sut = createSut();
childProcessProxyMock.proxy.dispose.resolves();
await sut.dispose();
expect(childProcessProxyMock.proxy.dispose).calledBefore(childProcessProxyMock.dispose);
});
it('should not reject when the child process is down', async () => {
+ const sut = createSut();
childProcessProxyMock.proxy.dispose.rejects(new ChildProcessCrashedError(1, '1'));
await sut.dispose();
expect(childProcessProxyMock.dispose).called;
});
it('should only wait 2 seconds for the test runner to be disposed', async () => {
+ const sut = createSut();
const testRunnerDisposeTask = new Task();
childProcessProxyMock.proxy.dispose.returns(testRunnerDisposeTask.promise);
const disposePromise = sut.dispose();