Skip to content

Commit

Permalink
feat(sandbox): support symlinking of node_modules anywhere
Browse files Browse the repository at this point in the history
Improve the way `symlinkNodeModules` works. 

With `symlinkNodeModules` (default is `true`), Stryker will symlink your current node_modules inside the sandbox directory it uses during mutation testing. This allows your dependencies to be resolved. 

However, you might use a [monorepo](https://en.wikipedia.org/wiki/Monorepo) setup (or some other kind), where your `node_modules` might not all reside inside the current working directory (often the _root_ directory). This change allows also `node_modules` inside your child directories to be symlinked.

Thanks @wwwzbwcom for contributing this feature!

Co-authored-by: Nico Jansen <jansennico@gmail.com>
  • Loading branch information
wwwzbwcom and nicojs committed Feb 11, 2021
1 parent 118f8c9 commit ee66623
Show file tree
Hide file tree
Showing 12 changed files with 156 additions and 56 deletions.
3 changes: 3 additions & 0 deletions e2e/test/mono-repo/.mocharc.jsonc
@@ -0,0 +1,3 @@
{
"spec": ["packages/*/test/**/*.js"]
}
5 changes: 5 additions & 0 deletions e2e/test/mono-repo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions e2e/test/mono-repo/package.json
@@ -0,0 +1,16 @@
{
"name": "mocha-repo",
"version": "0.0.0",
"private": true,
"description": "A module to perform an integration test",
"main": "index.js",
"scripts": {
"postinstall": "node tasks/bootstrap.js",
"pretest": "rimraf \"reports\"",
"test": "stryker run",
"test:unit": "mocha",
"posttest": "mocha --no-config --require ../../tasks/ts-node-register.js verify/*.ts"
},
"author": "",
"license": "ISC"
}
5 changes: 5 additions & 0 deletions e2e/test/mono-repo/packages/app/src/app.js
@@ -0,0 +1,5 @@
const foo = require('foo');

exports.concatWithFoo = (msg) => {
return foo + ': ' + msg;
}
8 changes: 8 additions & 0 deletions e2e/test/mono-repo/packages/app/test/app.spec.js
@@ -0,0 +1,8 @@
const assert = require('assert');
const app = require('../src/app');

describe('app', () => {
it('should concat with foo', () => {
assert.strictEqual(app.concatWithFoo('test'), 'foo: test');
});
});
13 changes: 13 additions & 0 deletions e2e/test/mono-repo/stryker.conf.json
@@ -0,0 +1,13 @@
{
"$schema": "../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "mocha",
"concurrency": 2,
"mutate": [
"packages/*/{src,lib}/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|jsx|tsx|html|vue)"
],
"coverageAnalysis": "perTest",
"reporters": ["clear-text", "html", "event-recorder"],
"plugins": [
"@stryker-mutator/mocha-runner"
]
}
5 changes: 5 additions & 0 deletions e2e/test/mono-repo/tasks/bootstrap.js
@@ -0,0 +1,5 @@
const fs = require('fs');
const path = require('path');
const fooNodeModule = path.resolve(__dirname, '..', 'packages', 'app', 'node_modules', 'foo');
fs.mkdirSync(fooNodeModule, { recursive: true });
fs.writeFileSync(path.resolve(fooNodeModule, 'index.js'), 'module.exports = "foo"');
11 changes: 11 additions & 0 deletions e2e/test/mono-repo/verify/verify.ts
@@ -0,0 +1,11 @@
import { expectMetrics } from '../../../helpers';

describe('Verify stryker has ran correctly', () => {

it('should report correct score', async () => {
await expectMetrics({
killed: 2
});
});

});
29 changes: 17 additions & 12 deletions packages/core/src/sandbox/sandbox.ts
Expand Up @@ -11,7 +11,7 @@ import { mergeMap, toArray } from 'rxjs/operators';
import { from } from 'rxjs';

import { TemporaryDirectory } from '../utils/temporary-directory';
import { findNodeModules, MAX_CONCURRENT_FILE_IO, moveDirectoryRecursiveSync, symlinkJunction, mkdirp } from '../utils/file-utils';
import { findNodeModulesList, MAX_CONCURRENT_FILE_IO, moveDirectoryRecursiveSync, symlinkJunction, mkdirp } from '../utils/file-utils';
import { coreTokens } from '../di';
import { UnexpectedExitHandler } from '../unexpected-exit-handler';

Expand Down Expand Up @@ -84,22 +84,27 @@ export class Sandbox implements Disposable {
}

private async symlinkNodeModulesIfNeeded(): Promise<void> {
this.log.info('Start symlink node_modules');
if (this.options.symlinkNodeModules && !this.options.inPlace) {
// TODO: Change with this.options.basePath when we have it
const basePath = process.cwd();
const nodeModules = await findNodeModules(basePath);
if (nodeModules) {
await symlinkJunction(nodeModules, path.join(this.workingDirectory, 'node_modules')).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'EEXIST') {
this.log.warn(
normalizeWhitespaces(`Could not symlink "${nodeModules}" in sandbox directory,
const nodeModulesList = await findNodeModulesList(basePath, this.options.tempDirName);

if (nodeModulesList.length > 0) {
for (const nodeModules of nodeModulesList) {
this.log.debug(`Create symlink from ${path.resolve(nodeModules)} to ${path.join(this.workingDirectory, nodeModules)}`);
await symlinkJunction(path.resolve(nodeModules), path.join(this.workingDirectory, nodeModules)).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'EEXIST') {
this.log.warn(
normalizeWhitespaces(`Could not symlink "${nodeModules}" in sandbox directory,
it is already created in the sandbox. Please remove the node_modules from your sandbox files.
Alternatively, set \`symlinkNodeModules\` to \`false\` to disable this warning.`)
);
} else {
this.log.warn(`Unexpected error while trying to symlink "${nodeModules}" in sandbox directory.`, error);
}
});
);
} else {
this.log.warn(`Unexpected error while trying to symlink "${nodeModules}" in sandbox directory.`, error);
}
});
}
} else {
this.log.warn(`Could not find a node_modules folder to symlink into the sandbox directory. Search "${basePath}" and its parent directories`);
}
Expand Down
33 changes: 20 additions & 13 deletions packages/core/src/utils/file-utils.ts
@@ -1,6 +1,5 @@
import path from 'path';
import fs from 'fs';

import { promisify } from 'util';

import nodeGlob from 'glob';
Expand Down Expand Up @@ -79,18 +78,26 @@ export function symlinkJunction(to: string, from: string): Promise<void> {
* returns the first occurrence of the node_modules, or null of none could be found.
* @param basePath starting point
*/
export async function findNodeModules(basePath: string): Promise<string | null> {
basePath = path.resolve(basePath);
const nodeModules = path.resolve(basePath, 'node_modules');
try {
await fs.promises.stat(nodeModules);
return nodeModules;
} catch (e) {
const parent = path.dirname(basePath);
if (parent === basePath) {
return null;
} else {
return findNodeModules(path.dirname(basePath));
export async function findNodeModulesList(basePath: string, tempDirName?: string): Promise<string[]> {
const nodeModulesList: string[] = [];
const dirBfsQueue: string[] = ['.'] ?? [];

let dir: string | undefined;
while ((dir = dirBfsQueue.pop())) {
if (path.basename(dir) === tempDirName) {
continue;
}

if (path.basename(dir) === 'node_modules') {
nodeModulesList.push(dir);
continue;
}

const parentDir = dir;
const filesWithType = await fs.promises.readdir(path.join(basePath, dir), { withFileTypes: true });
const dirs = filesWithType.filter((file) => file.isDirectory()).map((childDir) => path.join(parentDir, childDir.name));
dirBfsQueue.push(...dirs);
}

return nodeModulesList;
}
33 changes: 23 additions & 10 deletions packages/core/test/unit/sandbox/sandbox.spec.ts
Expand Up @@ -21,7 +21,7 @@ describe(Sandbox.name, () => {
let mkdirpStub: sinon.SinonStub;
let writeFileStub: sinon.SinonStub;
let symlinkJunctionStub: sinon.SinonStub;
let findNodeModulesStub: sinon.SinonStub;
let findNodeModulesListStub: sinon.SinonStub;
let execaMock: sinon.SinonStubbedInstance<typeof execa>;
let unexpectedExitHandlerMock: sinon.SinonStubbedInstance<UnexpectedExitHandler>;
let readFile: sinon.SinonStub;
Expand All @@ -35,7 +35,7 @@ describe(Sandbox.name, () => {
mkdirpStub = sinon.stub(fileUtils, 'mkdirp');
writeFileStub = sinon.stub(fsPromises, 'writeFile');
symlinkJunctionStub = sinon.stub(fileUtils, 'symlinkJunction');
findNodeModulesStub = sinon.stub(fileUtils, 'findNodeModules');
findNodeModulesListStub = sinon.stub(fileUtils, 'findNodeModulesList');
moveDirectoryRecursiveSyncStub = sinon.stub(fileUtils, 'moveDirectoryRecursiveSync');
readFile = sinon.stub(fsPromises, 'readFile');
execaMock = {
Expand All @@ -49,7 +49,7 @@ describe(Sandbox.name, () => {
dispose: sinon.stub(),
};
symlinkJunctionStub.resolves();
findNodeModulesStub.resolves('node_modules');
findNodeModulesListStub.resolves(['node_modules']);
files = [];
});

Expand Down Expand Up @@ -105,8 +105,8 @@ describe(Sandbox.name, () => {
it('should symlink node modules in sandbox directory if exists', async () => {
const sut = createSut();
await sut.init();
expect(findNodeModulesStub).calledWith(process.cwd());
expect(symlinkJunctionStub).calledWith('node_modules', path.join(SANDBOX_WORKING_DIR, 'node_modules'));
expect(findNodeModulesListStub).calledWith(process.cwd());
expect(symlinkJunctionStub).calledWith(path.resolve('node_modules'), path.join(SANDBOX_WORKING_DIR, 'node_modules'));
});
});

Expand Down Expand Up @@ -218,8 +218,21 @@ describe(Sandbox.name, () => {
await initPromise;
});

it('should symlink node modules in sandbox directory if node_modules exist', async () => {
findNodeModulesListStub.resolves(['node_modules', 'packages/a/node_modules']);
const sut = createSut();
await sut.init();

const calls = symlinkJunctionStub.getCalls();
expect(calls[0]).calledWithExactly(path.resolve('node_modules'), path.join(SANDBOX_WORKING_DIR, 'node_modules'));
expect(calls[1]).calledWithExactly(
path.resolve('packages', 'a', 'node_modules'),
path.join(SANDBOX_WORKING_DIR, 'packages', 'a', 'node_modules')
);
});

it('should not symlink node modules in sandbox directory if no node_modules exist', async () => {
findNodeModulesStub.resolves(null);
findNodeModulesListStub.resolves([]);
const sut = createSut();
await sut.init();
expect(testInjector.logger.warn).calledWithMatch('Could not find a node_modules');
Expand All @@ -228,7 +241,7 @@ describe(Sandbox.name, () => {
});

it('should log a warning if "node_modules" already exists in the working folder', async () => {
findNodeModulesStub.resolves('node_modules');
findNodeModulesListStub.resolves(['node_modules']);
symlinkJunctionStub.rejects(factory.fileAlreadyExistsError());
const sut = createSut();
await sut.init();
Expand All @@ -242,7 +255,7 @@ describe(Sandbox.name, () => {
});

it('should log a warning if linking "node_modules" results in an unknown error', async () => {
findNodeModulesStub.resolves('basePath/node_modules');
findNodeModulesListStub.resolves(['basePath/node_modules']);
const error = new Error('unknown');
symlinkJunctionStub.rejects(error);
const sut = createSut();
Expand All @@ -253,12 +266,12 @@ describe(Sandbox.name, () => {
);
});

it('should symlink node modules in sandbox directory if `symlinkNodeModules` is `false`', async () => {
it('should not symlink node modules in sandbox directory if `symlinkNodeModules` is `false`', async () => {
testInjector.options.symlinkNodeModules = false;
const sut = createSut();
await sut.init();
expect(symlinkJunctionStub).not.called;
expect(findNodeModulesStub).not.called;
expect(findNodeModulesListStub).not.called;
});

it('should execute the buildCommand in the sandbox', async () => {
Expand Down
51 changes: 30 additions & 21 deletions packages/core/test/unit/utils/file-utils.spec.ts
Expand Up @@ -6,13 +6,22 @@ import sinon from 'sinon';

import * as fileUtils from '../../../src/utils/file-utils';

function wrapDirs(dirs: string[]) {
return dirs.map((d) => {
return {
name: d,
isDirectory: () => true,
};
});
}

describe('fileUtils', () => {
let statStub: sinon.SinonStub;
let readdirStub: sinon.SinonStub;

beforeEach(() => {
sinon.stub(fs.promises, 'writeFile');
sinon.stub(fs.promises, 'symlink');
statStub = sinon.stub(fs.promises, 'stat');
readdirStub = sinon.stub(fs.promises, 'readdir');
});

describe('symlinkJunction', () => {
Expand All @@ -22,31 +31,31 @@ describe('fileUtils', () => {
});
});

describe('findNodeModules', () => {
it('should return node_modules located in `basePath`', async () => {
statStub.resolves();
describe('findNodeModulesList', () => {
it('should return node_modules array located in `basePath`', async () => {
const basePath = path.resolve('a', 'b', 'c');
const expectedNodeModules = path.join(basePath, 'node_modules');
const actual = await fileUtils.findNodeModules(basePath);
expect(actual).eq(expectedNodeModules);
const expectedNodeModules = path.join('node_modules');
readdirStub.resolves(wrapDirs(['node_modules']));
const actual = await fileUtils.findNodeModulesList(basePath);
expect(actual[0]).eq(expectedNodeModules);
});

it("should return node_modules located in parent directory of `basePath` if it didn't exist in base path", async () => {
const basePath = path.resolve('a', 'b', 'c');
const expectedNodeModules = path.resolve('a', 'node_modules');
statStub
.throws() // default
.withArgs(expectedNodeModules)
.resolves();
const actual = await fileUtils.findNodeModules(basePath);
expect(actual).eq(expectedNodeModules);
it('should return node_modules array in subDirectory of `basePath`', async () => {
const basePath = path.resolve('.');
const expectedNodeModulesList = [path.join('a', 'b', 'node_modules'), path.join('a', 'b', 'c', 'node_modules')];
readdirStub.withArgs(path.resolve(basePath)).resolves(wrapDirs(['a']));
readdirStub.withArgs(path.resolve(basePath, 'a')).resolves(wrapDirs(['b']));
readdirStub.withArgs(path.resolve(basePath, 'a', 'b')).resolves(wrapDirs(['c', 'node_modules']));
readdirStub.withArgs(path.resolve(basePath, 'a', 'b', 'c')).resolves(wrapDirs(['node_modules']));
const actual = await fileUtils.findNodeModulesList(basePath);
expect(actual).deep.eq(expectedNodeModulesList);
});

it('should return null if no node_modules exist in basePath or parent directories', async () => {
it('should return empty array if no node_modules exist in basePath or parent directories', async () => {
const basePath = path.resolve('a', 'b', 'c');
statStub.throws();
const actual = await fileUtils.findNodeModules(basePath);
expect(actual).null;
readdirStub.resolves([]);
const actual = await fileUtils.findNodeModulesList(basePath);
expect(actual.length).eq(0);
});
});
});

0 comments on commit ee66623

Please sign in to comment.