@stryker-mutator/tap-runner
Version:
A plugin to use the TAP (test anything protocol) test runner in Stryker, the JavaScript mutation testing framework
267 lines (246 loc) • 7.09 kB
text/typescript
import childProcess from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
import {
commonTokens,
Injector,
PluginContext,
tokens,
} from '@stryker-mutator/api/plugin';
import {
BaseTestResult,
determineHitLimitReached,
DryRunOptions,
DryRunResult,
DryRunStatus,
MutantRunOptions,
MutantRunResult,
TestResult,
TestRunner,
TestRunnerCapabilities,
TestStatus,
TimeoutDryRunResult,
toMutantRunResult,
} from '@stryker-mutator/api/test-runner';
import {
InstrumenterContext,
INSTRUMENTER_CONSTANTS,
MutantCoverage,
StrykerOptions,
} from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
import { normalizeFileName, testFilesProvided } from '@stryker-mutator/util';
import * as pluginTokens from './plugin-tokens.js';
import {
findTestyLookingFiles,
captureTapResult,
TapResult,
buildArguments,
} from './tap-helper.js';
import { TapRunnerOptionsWithStrykerOptions } from './tap-runner-options-with-stryker-options.js';
import {
strykerHitLimit,
strykerNamespace,
strykerDryRun,
tempTapOutputFileName,
} from './setup/env.cjs';
export function createTapTestRunnerFactory(
namespace:
| typeof INSTRUMENTER_CONSTANTS.NAMESPACE
| '__stryker2__' = INSTRUMENTER_CONSTANTS.NAMESPACE,
): {
(injector: Injector<PluginContext>): TapTestRunner;
inject: ['$injector'];
} {
createTapTestRunner.inject = tokens(commonTokens.injector);
function createTapTestRunner(injector: Injector<PluginContext>) {
return injector
.provideValue(pluginTokens.globalNamespace, namespace)
.injectClass(TapTestRunner);
}
return createTapTestRunner;
}
export const createTapTestRunner = createTapTestRunnerFactory();
class HitLimitError extends Error {
constructor(public readonly result: TimeoutDryRunResult) {
super(result.reason);
}
}
interface TapRunOptions {
disableBail: boolean;
activeMutant?: string;
hitLimit?: number;
dryRun?: boolean;
}
export class TapTestRunner implements TestRunner {
public static inject = tokens(
commonTokens.options,
commonTokens.logger,
pluginTokens.globalNamespace,
);
private testFiles: string[] = [];
private static readonly hookFile = normalizeFileName(
path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'setup',
'hook.cjs',
),
);
private readonly options: TapRunnerOptionsWithStrykerOptions;
constructor(
options: StrykerOptions,
private readonly log: Logger,
private readonly globalNamespace:
| typeof INSTRUMENTER_CONSTANTS.NAMESPACE
| '__stryker2__',
) {
this.options = options as TapRunnerOptionsWithStrykerOptions;
}
public capabilities():
| Promise<TestRunnerCapabilities>
| TestRunnerCapabilities {
return { reloadEnvironment: true };
}
public async init(): Promise<void> {
this.testFiles = await findTestyLookingFiles(this.options.tap.testFiles);
}
public async dryRun(options: DryRunOptions): Promise<DryRunResult> {
const testFilesToRun = testFilesProvided(options)
? options.testFiles!
: this.testFiles;
return this.run(
{ disableBail: options.disableBail, dryRun: true },
testFilesToRun,
);
}
public async mutantRun(options: MutantRunOptions): Promise<MutantRunResult> {
return toMutantRunResult(
await this.run(
{
disableBail: options.disableBail,
activeMutant: options.activeMutant.id,
hitLimit: options.hitLimit,
},
this.testFiles,
options.testFilter,
),
);
}
private async run(
testOptions: TapRunOptions,
testFilesToRun: string[],
testFilter?: string[],
): Promise<DryRunResult> {
const testFiles = testFilter ?? testFilesToRun;
const runs: TestResult[] = [];
const totalCoverage: MutantCoverage = {
static: {},
perTest: {},
};
for (const testFile of testFiles) {
try {
const { testResult, coverage } = await this.runFile(
testFile,
testOptions,
);
runs.push(testResult);
totalCoverage.perTest[testFile] = coverage?.static ?? {};
if (
testResult.status !== TestStatus.Success &&
!testOptions.disableBail
) {
break;
}
} catch (err) {
if (err instanceof HitLimitError) {
return err.result;
} else if (err instanceof Error) {
return {
status: DryRunStatus.Error,
errorMessage: `Error running file "${testFile}". ${err.message}`,
};
}
throw err;
}
}
return {
status: DryRunStatus.Complete,
tests: runs,
mutantCoverage: totalCoverage,
};
}
private async runFile(
testFile: string,
testOptions: TapRunOptions,
): Promise<{ testResult: TestResult; coverage: MutantCoverage | undefined }> {
const env: NodeJS.ProcessEnv = {
...process.env,
[strykerHitLimit]: testOptions.hitLimit?.toString(),
[strykerNamespace]: this.globalNamespace,
[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE]:
testOptions.activeMutant,
[strykerDryRun]: testOptions.dryRun?.toString(),
};
const args = buildArguments(
this.options.tap.nodeArgs,
TapTestRunner.hookFile,
testFile,
);
if (this.log.isDebugEnabled()) {
this.log.debug(
`Running: \`node ${args.map((arg) => `"${arg}"`).join(' ')}\` in ${process.cwd()}`,
);
}
const now = () => new Date().getTime();
const before = now();
const tapProcess = childProcess.spawn('node', args, { env });
const result = await captureTapResult(
tapProcess,
!testOptions.disableBail && this.options.tap.forceBail,
);
const timeSpentMs = now() - before;
const fileName = tempTapOutputFileName(tapProcess.pid);
const fileContent = await fs.readFile(fileName, 'utf-8');
await fs.rm(fileName);
const file = JSON.parse(fileContent) as InstrumenterContext;
const hitLimitReached = determineHitLimitReached(
file.hitCount,
testOptions.hitLimit,
);
if (hitLimitReached) {
throw new HitLimitError(hitLimitReached);
}
return {
testResult: this.tapResultToTestResult(testFile, result, timeSpentMs),
coverage: file.mutantCoverage,
};
}
private tapResultToTestResult(
fileName: string,
{ result, failedTests }: TapResult,
timeSpentMs: number,
): TestResult {
const generic: BaseTestResult = {
id: fileName,
name: fileName,
timeSpentMs: result.time ?? timeSpentMs,
fileName: fileName,
startPosition: undefined,
};
if (result.ok) {
return {
...generic,
status: TestStatus.Success,
};
} else {
return {
...generic,
status: TestStatus.Failed,
failureMessage:
failedTests.map((f) => `${f.fullname}: ${f.name}`).join(', ') ??
'Unknown issue',
};
}
}
}