Skip to content

Commit

Permalink
feat(tsconfig): rewrite tsconfig references (#2292)
Browse files Browse the repository at this point in the history
Add a new `tsconfigFile` option (reused from the old `@stryker-mutator/typescript` package). Use this new option in the `@stryker-mutator/typescript-checker` instead of a specific typescript-checker specific one

From stryker core, use this setting to rewrite `tsconfig.json` file's `references` and `extends` when they refer config files that fall outside of the sandbox.

For example:

```json
{
  "extends": "../../tsconfig.settings.json",
  "references": {
     "path": "../model"
  }
}
```

becomes:
```json
{
  "extends": "../../../../tsconfig.settings.json",
  "references": {
     "path": "../../../model"
  }
}
```



The implementation relies on typescript being available, but will only import it when it found a tsconfig.json file. Once a tsconfig file is found, referenced files within the sandbox also get rewritten.

Closes #2276
  • Loading branch information
nicojs committed Jul 5, 2020
1 parent 3adde83 commit 4ee4950
Show file tree
Hide file tree
Showing 92 changed files with 295 additions and 2,734 deletions.
5 changes: 5 additions & 0 deletions packages/api/schema/stryker-core.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,11 @@
},
"default": []
},
"tsconfigFile": {
"description": "Configure the (root) tsconfig file for typescript projects. This will allow Stryker to rewrite the `extends` and `references` settings in this and related tsconfig files in your sandbox. Defaults to `tsconfig.json`. This setting is also used when you enable the `@stryker-mutator/typescript-checker plugin",
"type": "string",
"default": "tsconfig.json"
},
"warnings": {
"default": true,
"oneOf": [
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/process/2-MutantInstrumenterExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { LoggingClientContext } from '../logging';
import { ConcurrencyTokenProvider, createCheckerPool } from '../concurrent';
import { createCheckerFactory } from '../checker/CheckerFacade';

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

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

export interface MutantInstrumenterContext extends MainContext {
Expand All @@ -27,7 +29,10 @@ export class MutantInstrumenterExecutor {

// Instrument files in-memory
const instrumentResult = await instrumenter.instrument(this.inputFiles.filesToMutate);
const files = this.replaceWith(instrumentResult);

// Rewrite tsconfig file references
const tsconfigFileRewriter = this.injector.injectClass(SandboxTSConfigRewriter);
const files = await tsconfigFileRewriter.rewrite(this.replaceWith(instrumentResult));

// Initialize the checker pool
const concurrencyTokenProviderProvider = this.injector.provideClass(coreTokens.concurrencyTokenProvider, ConcurrencyTokenProvider);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/sandbox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './sandbox';
export * from './sandbox-tsconfig-rewriter';
94 changes: 94 additions & 0 deletions packages/core/src/sandbox/sandbox-tsconfig-rewriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import path = require('path');

import { StrykerOptions, File } from '@stryker-mutator/api/core';
import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
import { Logger } from '@stryker-mutator/api/logging';

/**
* A helper class that rewrites `references` and `extends` file paths if they end up falling outside of the sandbox.
* @example
* {
* "extends": "../../tsconfig.settings.json",
* "references": {
* "path": "../model"
* }
* }
* becomes:
* {
* "extends": "../../../../tsconfig.settings.json",
* "references": {
* "path": "../../../model"
* }
* }
*/
export class SandboxTSConfigRewriter {
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[]> {
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;
}
}

private async rewriteTSConfigFile(tsconfigFileName: string): Promise<void> {
if (!this.touched.includes(tsconfigFileName)) {
this.touched.push(tsconfigFileName);
const tsconfigFile = this.fs.get(tsconfigFileName);
if (tsconfigFile) {
this.log.debug('Rewriting file %s', tsconfigFile);
const ts = await import('typescript');
const { config } = ts.parseConfigFileTextToJson(tsconfigFile.name, tsconfigFile.textContent);
if (config) {
await this.rewriteExtends(config, tsconfigFileName);
await this.rewriteProjectReferences(config, tsconfigFileName);
this.fs.set(tsconfigFileName, new File(tsconfigFileName, JSON.stringify(config, null, 2)));
}
}
}
}

private async rewriteExtends(config: any, tsconfigFileName: string): Promise<boolean> {
if (typeof config.extends === 'string') {
const extendsFileName = path.resolve(path.dirname(tsconfigFileName), config.extends);
const relativeToSandbox = path.relative(process.cwd(), extendsFileName);
if (relativeToSandbox.startsWith('..')) {
config.extends = this.join('..', '..', config.extends);
return true;
} else {
await this.rewriteTSConfigFile(extendsFileName);
}
}
return false;
}

private async rewriteProjectReferences(config: { references?: Array<{ path: string }> }, originTSConfigFileName: string): Promise<void> {
const ts = await import('typescript');
if (Array.isArray(config.references)) {
for (const reference of config.references) {
const referencePath = ts.resolveProjectReferencePath(reference);
const referencedProjectFileName = path.resolve(path.dirname(originTSConfigFileName), referencePath);
const relativeToProject = path.relative(process.cwd(), referencedProjectFileName);
if (relativeToProject.startsWith('..')) {
reference.path = this.join('..', '..', referencePath);
} else {
await this.rewriteTSConfigFile(referencedProjectFileName);
}
}
}
}

private join(...pathSegments: string[]) {
return pathSegments.map((segment) => segment.replace(/\\/g, '/')).join('/');
}
}
12 changes: 9 additions & 3 deletions packages/core/src/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { findNodeModules, symlinkJunction, writeFile, isJSOrFriend } from '../ut
import { coreTokens } from '../di';

interface SandboxFactory {
(options: StrykerOptions, getLogger: LoggerFactoryMethod, files: File[], tempDir: I<TemporaryDirectory>, exec: typeof execa): Promise<Sandbox>;
(options: StrykerOptions, getLogger: LoggerFactoryMethod, files: readonly File[], tempDir: I<TemporaryDirectory>, exec: typeof execa): Promise<
Sandbox
>;
inject: [
typeof commonTokens.options,
typeof commonTokens.getLogger,
Expand All @@ -36,7 +38,7 @@ export class Sandbox {
private readonly options: StrykerOptions,
private readonly log: Logger,
temporaryDirectory: I<TemporaryDirectory>,
private readonly files: File[],
private readonly files: readonly File[],
private readonly exec: typeof execa
) {
this.workingDirectory = temporaryDirectory.createRandomDirectory('sandbox');
Expand All @@ -53,7 +55,7 @@ export class Sandbox {
async (
options: StrykerOptions,
getLogger: LoggerFactoryMethod,
files: File[],
files: readonly File[],
tempDir: I<TemporaryDirectory>,
exec: typeof execa
): Promise<Sandbox> => {
Expand All @@ -68,6 +70,10 @@ export class Sandbox {
return [...this.fileMap.entries()].map(([, to]) => to);
}

public sandboxFileFor(fileName: string) {
return this.fileMap.get(fileName);
}

private fillSandbox(): Promise<void[]> {
const copyPromises = this.files.map((file) => this.fillFile(file));
return Promise.all(copyPromises);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import { Checker } from '@stryker-mutator/api/check';

import { MutantInstrumenterExecutor } from '../../../src/process';
import InputFileCollection from '../../../src/input/InputFileCollection';
import { Sandbox } from '../../../src/sandbox/sandbox';
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';

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 instrumentResult: InstrumentResult;
let sandboxMock: sinon.SinonStubbedInstance<Sandbox>;
let checkerPoolMock: PoolMock<Checker>;
Expand All @@ -39,10 +40,13 @@ describe(MutantInstrumenterExecutor.name, () => {
};
sandboxMock = sinon.createStubInstance(Sandbox);
instrumenterMock = sinon.createStubInstance(Instrumenter);
sandboxTSConfigRewriterMock = sinon.createStubInstance(SandboxTSConfigRewriter);
sandboxTSConfigRewriterMock.rewrite.resolves([mutatedFile, testFile]);
inputFiles = new InputFileCollection([originalFile, testFile], [mutatedFile.name]);
injectorMock = factory.injector();
sut = new MutantInstrumenterExecutor(injectorMock, inputFiles);
injectorMock.injectClass.withArgs(Instrumenter).returns(instrumenterMock);
injectorMock.injectClass.withArgs(SandboxTSConfigRewriter).returns(sandboxTSConfigRewriterMock);
injectorMock.injectFunction.withArgs(Sandbox.create).returns(sandboxMock);
injectorMock.resolve
.withArgs(coreTokens.concurrencyTokenProvider)
Expand All @@ -62,6 +66,12 @@ describe(MutantInstrumenterExecutor.name, () => {
expect(result).eq(injectorMock);
});

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

it('should provide the mutated files to the sandbox', async () => {
await sut.execute();
expect(injectorMock.provideValue).calledWithExactly(coreTokens.files, [mutatedFile, testFile]);
Expand Down
116 changes: 116 additions & 0 deletions packages/core/test/unit/sandbox/sandbox-tsconfig-rewriter.it.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import path = require('path');

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

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

describe(SandboxTSConfigRewriter.name, () => {
let files: File[];
let sut: SandboxTSConfigRewriter;

beforeEach(() => {
files = [];
sut = testInjector.injector.injectClass(SandboxTSConfigRewriter);
});

it('should not do anything if the tsconfig file does not exist', async () => {
files.push(new File('foo.js', 'console.log("foo");'));
const output = await sut.rewrite(files);
expect(output).eq(files);
});

it('should ignore missing "extends"', async () => {
files.push(tsconfigFile('tsconfig.json', { references: [{ path: './tsconfig.src.json' }] }));
const output = await sut.rewrite(files);
expect(output).deep.eq(files);
});

it('should ignore missing "references"', async () => {
files.push(tsconfigFile('tsconfig.json', { extends: './tsconfig.settings.json' }));
const output = await sut.rewrite(files);
expect(output).deep.eq(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.rewrite(files);
expect(output).deep.eq([tsconfigFile('tsconfig.json', { extends: '../../../tsconfig.settings.json' })]);
});

it('should support comments and other settings', async () => {
files.push(
new File(
path.resolve(process.cwd(), 'tsconfig.json'),
`{
"extends": "../tsconfig.settings.json",
"compilerOptions": {
// Here are the options
"target": "es5", // and a trailing comma
}
}`
)
);
const output = await sut.rewrite(files);
expect(output).deep.eq([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.rewrite(files);
expect(output).deep.eq([tsconfigFile('tsconfig.json', { references: [{ path: '../../../model/tsconfig.json' }] })]);
});

it('should rewrite referenced tsconfig files that are also located in the sandbox', async () => {
files.push(tsconfigFile('tsconfig.json', { extends: './tsconfig.settings.json', references: [{ path: './src' }] }));
files.push(tsconfigFile('tsconfig.settings.json', { extends: '../../tsconfig.root-settings.json' }));
files.push(tsconfigFile('src/tsconfig.json', { references: [{ path: '../../model' }] }));
const output = await sut.rewrite(files);
expect(output).deep.eq([
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' }] }),
]);
});

it('should be able to rewrite a monorepo style project', async () => {
// Arrange
files.push(
tsconfigFile('tsconfig.root.json', {
extends: '../../tsconfig.settings.json',
references: [{ path: 'src' }, { path: 'test/tsconfig.test.json' }],
})
);
files.push(tsconfigFile('src/tsconfig.json', { extends: '../../../tsconfig.settings.json', references: [{ path: '../../model' }] }));
files.push(tsconfigFile('test/tsconfig.test.json', { extends: '../tsconfig.root.json', references: [{ path: '../src' }] }));
testInjector.options.tsconfigFile = 'tsconfig.root.json';

// Act
const actual = await sut.rewrite(files);

// Assert
const expected = [
tsconfigFile('tsconfig.root.json', {
extends: '../../../../tsconfig.settings.json',
references: [{ path: 'src' }, { path: 'test/tsconfig.test.json' }],
}),
tsconfigFile('src/tsconfig.json', {
extends: '../../../../../tsconfig.settings.json',
references: [{ path: '../../../../model/tsconfig.json' }],
}),
tsconfigFile('test/tsconfig.test.json', { extends: '../tsconfig.root.json', references: [{ path: '../src' }] }),
];
expect(actual).deep.eq(expected);
});

function tsconfigFile(fileName: string, content: TSConfig) {
return new File(path.resolve(fileName), JSON.stringify(content, null, 2));
}

interface TSConfig {
extends?: string;
references?: Array<{ path: string }>;
[key: string]: unknown;
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.settings.json",
"references": [
{
"path": "../../model"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.root.json",
"references": [
{
"path": "../src"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.settings.json",
"files": [],
"references": [
{
"path": "src"
},
{
"path": "test/tsconfig.test.json"
}
]
}
Loading

0 comments on commit 4ee4950

Please sign in to comment.