Skip to content

Commit

Permalink
feat(core): add ability to override file headers (#2363)
Browse files Browse the repository at this point in the history
Add the ability to override the file headers inside a sandbox. This allows users to opt-out of the default header, as well as add custom headers.

You can configure it like this:

```json
{
  "sandboxFileHeaders": {
  "**/*.ts": "// Hello world\n"
  }
}
```

The key here is a [glob expression](https://globster.xyz/), where the value points to the header to be used for matching files.
  • Loading branch information
nicojs committed Aug 7, 2020
1 parent d0aa5c3 commit 430d6d3
Show file tree
Hide file tree
Showing 20 changed files with 304 additions and 63 deletions.
16 changes: 15 additions & 1 deletion packages/api/schema/stryker-core.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,10 @@
"errorMessage": "should be an \"object\" describing the mutator or a \"string\". See https://github.com/stryker-mutator/stryker/tree/master/packages/core#mutator."
},
"packageManager": {
"enum": ["npm", "yarn"],
"enum": [
"npm",
"yarn"
],
"description": "The package manager Stryker can use to install missing dependencies."
},
"plugins": {
Expand Down Expand Up @@ -344,6 +347,17 @@
"description": "The options for the html reporter",
"$ref": "#/definitions/htmlReporterOptions"
},
"sandboxFileHeaders": {
"type": "object",
"title": "SandboxFileHeaders",
"description": "Configure additional headers to be added to files inside your sandbox. These headers will be added after Stryker has instrumented your code with mutants, but before a test runner or build command is executed. This can be used to ignore typescript compile errors and eslint warnings that might have been added in the process of instrumenting your code. The default setting should work for most use cases.",
"additionalProperties": {
"type": "string"
},
"default": {
"**/*+(.js|.ts|.cjs|.mjs)?(x)": "/* eslint-disable */\n// @ts-nocheck\n"
}
},
"symlinkNodeModules": {
"description": "The 'symlinkNodeModules' value indicates whether Stryker should create a symbolic link to your current node_modules directory in the sandbox directories. This makes running your tests by Stryker behave more like your would run the tests yourself in your project directory. Only disable this setting if you really know what you are doing.",
"type": "boolean",
Expand Down
16 changes: 15 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ You can *ignore* files by adding an exclamation mark (`!`) at the start of an ex
* [mutator](#mutator)
* [plugins](#plugins)
* [reporters](#reporters)
* [sandboxFileHeaders](#sandboxFileHeaders)
* [symlinkNodeModules](#symlinkNodeModules)
* [tempDirName](#tempDirName)
* [testFramework](#testFramework)
Expand Down Expand Up @@ -292,6 +293,19 @@ The `clear-text` reporter supports three additional config options:

The `dashboard` reporter sends a report to https://dashboard.stryker-mutator.io, enabling you to add a mutation score badge to your readme, as well as hosting your html report on the dashboard. It uses the [dashboard.*](#dashboard) configuration options. See [the Stryker handbook](https://github.com/stryker-mutator/stryker-handbook/blob/master/dashboard.md) for more info.

<a name="sandboxFileHeaders"></a>
### `sandboxFileHeaders` [`object`]

Default: `{ "**/*+(.js|.ts|.cjs|.mjs)?(x)": "/* eslint-disable */\n// @ts-nocheck\n" }`
Command line: *none*
Config file: `sandboxFileHeaders: {}`

Configure additional headers to be added to files inside your sandbox. These headers will be added after Stryker has instrumented your code with mutants, but before a test runner or build command is executed. This can be used to ignore typescript compile errors and eslint warnings that might have been added in the process of instrumenting your code.

The key here is a [glob expression](https://globster.xyz/), where the value points to the header to be used for matching files.

*Note: The default setting should work for most use cases, only change this if you know what you are doing.*

<a name="symlinkNodeModules"></a>
### `symlinkNodeModules` [`boolean`]

Expand Down Expand Up @@ -330,7 +344,7 @@ not check-in your chosen temp directory in your `.gitignore` file.

Default: *none*
Command line: `--testFramework jasmine`
Config file: `testFramework: 'jasmine'`
Config file: `testFramework: 'jasmine'`

Configure which test framework you are using.
This option is not mandatory, as Stryker is test framework agnostic (it doesn't care what framework you use),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"lodash.flatmap": "^4.5.0",
"lodash.groupby": "^4.6.0",
"log4js": "6.2.1",
"minimatch": "^3.0.4",
"mkdirp": "~1.0.3",
"mutation-testing-elements": "~1.3.0",
"mutation-testing-metrics": "~1.3.0",
Expand All @@ -87,6 +88,7 @@
"@types/inquirer": "~6.0.2",
"@types/lodash.flatmap": "^4.5.6",
"@types/lodash.groupby": "^4.6.6",
"@types/minimatch": "^3.0.3",
"@types/node": "^14.0.1",
"@types/progress": "~2.0.1",
"flatted": "^3.0.2"
Expand Down
11 changes: 5 additions & 6 deletions packages/core/src/process/2-MutantInstrumenterExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import { LoggingClientContext } from '../logging';

import { ConcurrencyTokenProvider, createCheckerPool } from '../concurrent';
import { createCheckerFactory } from '../checker/CheckerFacade';

import { SandboxTSConfigRewriter } from '../sandbox';
import { createPreprocessor } from '../sandbox';

import { DryRunContext } from './3-DryRunExecutor';

Expand All @@ -34,9 +33,9 @@ export class MutantInstrumenterExecutor {
// Instrument files in-memory
const instrumentResult = await instrumenter.instrument(this.inputFiles.filesToMutate, { plugins: this.mutatorDescriptor.plugins });

// Rewrite tsconfig file references
const tsconfigFileRewriter = this.injector.injectClass(SandboxTSConfigRewriter);
const files = await tsconfigFileRewriter.rewrite(this.replaceWith(instrumentResult));
// Preprocess sandbox files
const tsconfigFileRewriter = this.injector.injectFunction(createPreprocessor);
const files = await tsconfigFileRewriter.preprocess(this.replaceInstrumentedFiles(instrumentResult));

// Initialize the checker pool
const concurrencyTokenProviderProvider = this.injector.provideClass(coreTokens.concurrencyTokenProvider, ConcurrencyTokenProvider);
Expand All @@ -54,7 +53,7 @@ export class MutantInstrumenterExecutor {
return checkerPoolProvider.provideValue(coreTokens.sandbox, sandbox).provideValue(coreTokens.mutants, instrumentResult.mutants);
}

private replaceWith(instrumentResult: InstrumentResult): File[] {
private replaceInstrumentedFiles(instrumentResult: InstrumentResult): File[] {
return this.inputFiles.files.map((inputFile) => {
const instrumentedFile = instrumentResult.files.find((instrumentedFile) => instrumentedFile.name === inputFile.name);
if (instrumentedFile) {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/sandbox/create-preprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { tokens, Injector, commonTokens, OptionsContext } from '@stryker-mutator/api/plugin';

import { SandboxTSConfigPreprocessor } from './sandbox-tsconfig-preprocessor';
import { SandboxFileHeaderPreprocessor } from './sandbox-file-header-preprocessor';
import { FilePreprocessor } from './file-preprocessor';
import { MultiPreprocessor } from './multi-preprocessor';

createPreprocessor.inject = tokens(commonTokens.injector);
export function createPreprocessor(injector: Injector<OptionsContext>): FilePreprocessor {
return new MultiPreprocessor([injector.injectClass(SandboxTSConfigPreprocessor), injector.injectClass(SandboxFileHeaderPreprocessor)]);
}
10 changes: 10 additions & 0 deletions packages/core/src/sandbox/file-preprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { File } from '@stryker-mutator/api/core';

/**
* A preprocessor changes files before writing them to the sandbox.
* Stuff like rewriting references tsconfig.json files or adding // @ts-nocheck
* This is a private api that we might want to open up in the future.
*/
export interface FilePreprocessor {
preprocess(file: File[]): Promise<File[]>;
}
3 changes: 2 additions & 1 deletion packages/core/src/sandbox/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export type { FilePreprocessor } from './file-preprocessor';
export * from './sandbox';
export * from './sandbox-tsconfig-rewriter';
export * from './create-preprocessor';
14 changes: 14 additions & 0 deletions packages/core/src/sandbox/multi-preprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { File } from '@stryker-mutator/api/core';

import { FilePreprocessor } from './file-preprocessor';

export class MultiPreprocessor implements FilePreprocessor {
constructor(private readonly preprocessors: FilePreprocessor[]) {}

public async preprocess(files: File[]): Promise<File[]> {
for await (const preprocessor of this.preprocessors) {
files = await preprocessor.preprocess(files);
}
return files;
}
}
27 changes: 27 additions & 0 deletions packages/core/src/sandbox/sandbox-file-header-preprocessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import path = require('path');

import { File, StrykerOptions } from '@stryker-mutator/api/core';
import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
import minimatch = require('minimatch');

import { FilePreprocessor } from './file-preprocessor';

/**
* https://github.com/stryker-mutator/stryker/issues/2276
*/
export class SandboxFileHeaderPreprocessor implements FilePreprocessor {
public static readonly inject = tokens(commonTokens.options);

constructor(private readonly options: StrykerOptions) {}

public async preprocess(files: File[]): Promise<File[]> {
return files.map((file) => {
Object.entries(this.options.sandboxFileHeaders).forEach(([pattern, header]) => {
if (minimatch(path.resolve(file.name), path.resolve(pattern))) {
file = new File(file.name, `${header}${file.textContent}`);
}
});
return file;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { StrykerOptions, File } from '@stryker-mutator/api/core';
import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
import { Logger } from '@stryker-mutator/api/logging';

import { FilePreprocessor } from './file-preprocessor';

/**
* A helper class that rewrites `references` and `extends` file paths if they end up falling outside of the sandbox.
* @example
Expand All @@ -21,13 +23,13 @@ import { Logger } from '@stryker-mutator/api/logging';
* }
* }
*/
export class SandboxTSConfigRewriter {
export class SandboxTSConfigPreprocessor implements FilePreprocessor {
private readonly touched: string[] = [];
private readonly fs = new Map<string, File>();
public static readonly inject = tokens(commonTokens.logger, commonTokens.options);
constructor(private readonly log: Logger, private readonly options: StrykerOptions) {}

public async rewrite(input: File[]): Promise<readonly File[]> {
public async preprocess(input: File[]): Promise<File[]> {
const tsconfigFile = path.resolve(this.options.tsconfigFile);
if (input.find((file) => file.name === tsconfigFile)) {
this.fs.clear();
Expand Down
13 changes: 2 additions & 11 deletions packages/core/src/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Logger, LoggerFactoryMethod } from '@stryker-mutator/api/logging';
import { tokens, commonTokens } from '@stryker-mutator/api/plugin';

import { TemporaryDirectory } from '../utils/TemporaryDirectory';
import { findNodeModules, symlinkJunction, writeFile, isJSOrFriend } from '../utils/fileUtils';
import { findNodeModules, symlinkJunction, writeFile } from '../utils/fileUtils';
import { coreTokens } from '../di';

interface SandboxFactory {
Expand All @@ -26,10 +26,6 @@ interface SandboxFactory {
];
}

const JS_HEADER = Buffer.from(`/* eslint-disable */
// @ts-nocheck
`);

export class Sandbox {
private readonly fileMap = new Map<string, string>();
public readonly workingDirectory: string;
Expand Down Expand Up @@ -121,11 +117,6 @@ export class Sandbox {
mkdirp.sync(folderName);
const targetFileName = path.join(folderName, path.basename(relativePath));
this.fileMap.set(file.name, targetFileName);
if (isJSOrFriend(file.name)) {
// see https://github.com/stryker-mutator/stryker/issues/2276
return writeFile(targetFileName, Buffer.concat([JS_HEADER, file.content]));
} else {
return writeFile(targetFileName, file.content);
}
return writeFile(targetFileName, file.content);
}
}
10 changes: 0 additions & 10 deletions packages/core/src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,6 @@ export function importModule(moduleName: string): unknown {
return require(moduleName);
}

const JS_OR_FRIEND_EXTENSION = Object.freeze(['.js', '.ts', '.jsx', '.tsx', '.cjs', '.mjs']);
/**
* Returns true if given file is a js or friend file (ts/js/jsx, etc)
* @param fileName The file name
*/
export function isJSOrFriend(fileName: string): boolean {
const ext = path.extname(fileName);
return JS_OR_FRIEND_EXTENSION.includes(ext.toLowerCase());
}

/**
* Writes data to a specified file.
* @param fileName The path to the file.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import path = require('path');

import { testInjector, assertions } from '@stryker-mutator/test-helpers';
import { File } from '@stryker-mutator/api/core';

import { FilePreprocessor, createPreprocessor } from '../../../src/sandbox';

describe('File preprocessor integration', () => {
let sut: FilePreprocessor;

beforeEach(() => {
sut = testInjector.injector.injectFunction(createPreprocessor);
});

it('should rewrite tsconfig files', async () => {
const output = await sut.preprocess([new File(path.resolve('tsconfig.json'), '{"extends": "../tsconfig.settings.json" }')]);
assertions.expectTextFilesEqual(output, [new File(path.resolve('tsconfig.json'), '{\n "extends": "../../../tsconfig.settings.json"\n}')]);
});

it('should add a header to .ts files', async () => {
const output = await sut.preprocess([new File(path.resolve('app.ts'), 'foo.bar()')]);
assertions.expectTextFilesEqual(output, [new File(path.resolve('app.ts'), '/* eslint-disable */\n// @ts-nocheck\nfoo.bar()')]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import InputFileCollection from '../../../src/input/InputFileCollection';
import { coreTokens } from '../../../src/di';
import { createConcurrencyTokenProviderMock, createCheckerPoolMock, PoolMock, ConcurrencyTokenProviderMock } from '../../helpers/producers';
import { createCheckerFactory } from '../../../src/checker/CheckerFacade';
import { SandboxTSConfigRewriter, Sandbox } from '../../../src/sandbox';
import { createPreprocessor, FilePreprocessor, Sandbox } from '../../../src/sandbox';

describe(MutantInstrumenterExecutor.name, () => {
let sut: MutantInstrumenterExecutor;
let inputFiles: InputFileCollection;
let injectorMock: sinon.SinonStubbedInstance<Injector>;
let instrumenterMock: sinon.SinonStubbedInstance<Instrumenter>;
let sandboxTSConfigRewriterMock: sinon.SinonStubbedInstance<SandboxTSConfigRewriter>;
let sandboxFilePreprocessorMock: sinon.SinonStubbedInstance<FilePreprocessor>;
let instrumentResult: InstrumentResult;
let sandboxMock: sinon.SinonStubbedInstance<Sandbox>;
let checkerPoolMock: PoolMock<Checker>;
Expand All @@ -41,14 +41,16 @@ describe(MutantInstrumenterExecutor.name, () => {
};
sandboxMock = sinon.createStubInstance(Sandbox);
instrumenterMock = sinon.createStubInstance(Instrumenter);
sandboxTSConfigRewriterMock = sinon.createStubInstance(SandboxTSConfigRewriter);
sandboxTSConfigRewriterMock.rewrite.resolves([mutatedFile, testFile]);
sandboxFilePreprocessorMock = {
preprocess: sinon.stub(),
};
sandboxFilePreprocessorMock.preprocess.resolves([mutatedFile, testFile]);
inputFiles = new InputFileCollection([originalFile, testFile], [mutatedFile.name]);
injectorMock = factory.injector();
mutatorDescriptor = factory.mutatorDescriptor({ plugins: ['functionSent'] });
sut = new MutantInstrumenterExecutor(injectorMock, inputFiles, mutatorDescriptor);
injectorMock.injectClass.withArgs(Instrumenter).returns(instrumenterMock);
injectorMock.injectClass.withArgs(SandboxTSConfigRewriter).returns(sandboxTSConfigRewriterMock);
injectorMock.injectFunction.withArgs(createPreprocessor).returns(sandboxFilePreprocessorMock);
injectorMock.injectFunction.withArgs(Sandbox.create).returns(sandboxMock);
injectorMock.resolve
.withArgs(coreTokens.concurrencyTokenProvider)
Expand All @@ -69,10 +71,10 @@ describe(MutantInstrumenterExecutor.name, () => {
expect(result).eq(injectorMock);
});

it('should rewrite tsconfig files before initializing the sandbox', async () => {
it('should preprocess files before initializing the sandbox', async () => {
await sut.execute();
expect(sandboxTSConfigRewriterMock.rewrite).calledWithExactly([mutatedFile, testFile]);
expect(sandboxTSConfigRewriterMock.rewrite).calledBefore(injectorMock.injectFunction);
expect(sandboxFilePreprocessorMock.preprocess).calledWithExactly([mutatedFile, testFile]);
expect(sandboxFilePreprocessorMock.preprocess).calledBefore(injectorMock.injectFunction);
});

it('should provide the mutated files to the sandbox', async () => {
Expand Down
36 changes: 36 additions & 0 deletions packages/core/test/unit/sandbox/multi-preprocessor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import sinon = require('sinon');
import { File } from '@stryker-mutator/api/core';
import { expect } from 'chai';

import { MultiPreprocessor } from '../../../src/sandbox/multi-preprocessor';
import { FilePreprocessor } from '../../../src/sandbox';

describe(MultiPreprocessor.name, () => {
describe(MultiPreprocessor.prototype.preprocess.name, () => {
it('should call preprocess on each preprocessor in order', async () => {
// Arrange
const input = [new File('foo.js', 'input')];
const firstResult = [new File('foo.js', 'first')];
const secondResult = [new File('foo.js', 'second')];
const first = createFilePreprocessorMock();
const second = createFilePreprocessorMock();
first.preprocess.resolves(firstResult);
second.preprocess.resolves(secondResult);
const sut = new MultiPreprocessor([first, second]);

// Act
const actual = await sut.preprocess(input);

// Assert
expect(actual).eq(secondResult);
expect(first.preprocess).calledWith(input);
expect(second.preprocess).calledWith(firstResult);
});
});

function createFilePreprocessorMock(): sinon.SinonStubbedInstance<FilePreprocessor> {
return {
preprocess: sinon.stub(),
};
}
});
Loading

0 comments on commit 430d6d3

Please sign in to comment.