@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
153 lines (135 loc) • 4.66 kB
text/typescript
import { exec } from 'child_process';
import os from 'os';
import { StrykerOptions, CommandRunnerOptions, INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core';
import {
TestRunner,
TestStatus,
MutantRunOptions,
DryRunResult,
MutantRunResult,
DryRunStatus,
ErrorDryRunResult,
CompleteDryRunResult,
toMutantRunResult,
TestRunnerCapabilities,
} from '@stryker-mutator/api/test-runner';
import { errorToString } from '@stryker-mutator/util';
import { objectUtils } from '../utils/object-utils.js';
import { Timer } from '../utils/timer.js';
/**
* A test runner that uses a (bash or cmd) command to execute the tests.
* Does not know hom many tests are executed or any code coverage results,
* instead, it mimics a simple test result based on the exit code.
* The command can be configured, but defaults to `npm test`.
*/
export class CommandTestRunner implements TestRunner {
/**
* "command"
*/
public static readonly runnerName = CommandTestRunner.name.replace('TestRunner', '').toLowerCase();
/**
* Determines whether a given name is "command" (ignore case)
* @param name Maybe "command", maybe not
*/
public static is(name: string): name is 'command' {
return this.runnerName === name.toLowerCase();
}
private readonly settings: CommandRunnerOptions;
private timeoutHandler: (() => Promise<void>) | undefined;
constructor(
private readonly workingDir: string,
options: StrykerOptions,
) {
this.settings = options.commandRunner;
}
public capabilities(): TestRunnerCapabilities {
// Can reload, because each call is a new process.
return { reloadEnvironment: true };
}
public async dryRun(): Promise<DryRunResult> {
return this.run({});
}
public async mutantRun({ activeMutant }: Pick<MutantRunOptions, 'activeMutant'>): Promise<MutantRunResult> {
const result = await this.run({ activeMutantId: activeMutant.id });
return toMutantRunResult(result);
}
private run({ activeMutantId }: { activeMutantId?: string }): Promise<DryRunResult> {
const timerInstance = new Timer();
return new Promise((res, rej) => {
const output: Array<Buffer | string> = [];
const env =
activeMutantId === undefined ? process.env : { ...process.env, [INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]: activeMutantId };
const childProcess = exec(this.settings.command, { cwd: this.workingDir, env });
childProcess.on('error', (error) => {
objectUtils
.kill(childProcess.pid)
.then(() => handleResolve(errorResult(error)))
.catch(rej);
});
childProcess.on('exit', (code) => {
const result = completeResult(code, timerInstance);
handleResolve(result);
});
childProcess.stdout!.on('data', (chunk) => {
output.push(chunk as Buffer);
});
childProcess.stderr!.on('data', (chunk) => {
output.push(chunk as Buffer);
});
this.timeoutHandler = async () => {
handleResolve({ status: DryRunStatus.Timeout });
await objectUtils.kill(childProcess.pid);
};
const handleResolve = (runResult: DryRunResult) => {
removeAllListeners();
this.timeoutHandler = undefined;
res(runResult);
};
function removeAllListeners() {
childProcess.stderr!.removeAllListeners();
childProcess.stdout!.removeAllListeners();
childProcess.removeAllListeners();
}
function errorResult(error: Error): ErrorDryRunResult {
return {
errorMessage: errorToString(error),
status: DryRunStatus.Error,
};
}
function completeResult(exitCode: number | null, timer: Timer): CompleteDryRunResult {
const duration = timer.elapsedMs();
if (exitCode === 0) {
return {
status: DryRunStatus.Complete,
tests: [
{
id: 'all',
name: 'All tests',
status: TestStatus.Success,
timeSpentMs: duration,
},
],
};
} else {
return {
status: DryRunStatus.Complete,
tests: [
{
id: 'all',
failureMessage: output.map((buf) => buf.toString()).join(os.EOL),
name: 'All tests',
status: TestStatus.Failed,
timeSpentMs: duration,
},
],
};
}
}
});
}
public async dispose(): Promise<void> {
if (this.timeoutHandler) {
await this.timeoutHandler();
}
}
}