Skip to content

Commit

Permalink
feat(temp-dir): allow multiple instances to share the same temp-dir (#…
Browse files Browse the repository at this point in the history
…5120)

* feat(temp-dir): allow multiple instances to share the same temp-dir

Allow multiple runs of Stryker to share the same temp dir. This means that Stryker will _no longer remove the entire `.stryker-tmp` directory when it ran successfully (since other `sandbox-xxx` directories could be owned by other processes).

Note: this is implemented in preparation for the implementation of the mutation server protocol (#5086)

* fix test on windows
  • Loading branch information
nicojs authored Nov 24, 2024
1 parent e8a91ac commit d15453e
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 141 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/reporters/event-recorder-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class EventRecorderReporter implements StrictReporter {
private readonly log: Logger,
private readonly options: StrykerOptions,
) {
this.createBaseFolderTask = fileUtils.cleanFolder(this.options.eventReporter.baseDir);
this.createBaseFolderTask = fileUtils.cleanDir(this.options.eventReporter.baseDir);
}

private writeToFile(methodName: keyof Reporter, data: any) {
Expand Down
22 changes: 9 additions & 13 deletions packages/core/src/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,10 @@ export class Sandbox implements Disposable {
* Either an actual sandbox directory, or the cwd when running in --inPlace mode
*/
public readonly workingDirectory: string;
/**
/**11
* The backup directory when running in --inPlace mode
*/
private readonly backupDirectory: string = '';
/**
* The sandbox dir or the backup dir when running in `--inPlace` mode
*/
private readonly tempDirectory: string;

public static readonly inject = tokens(
commonTokens.options,
Expand All @@ -41,32 +37,32 @@ export class Sandbox implements Disposable {
coreTokens.unexpectedExitRegistry,
);

/**
* @param temporaryDirectory The sandbox dir or the backup dir when running in `--inPlace` mode
*/
constructor(
private readonly options: StrykerOptions,
private readonly log: Logger,
private readonly temporaryDirectory: I<TemporaryDirectory>,
temporaryDirectory: I<TemporaryDirectory>,
private readonly project: Project,
private readonly execCommand: typeof execaCommand,
unexpectedExitHandler: I<UnexpectedExitHandler>,
) {
if (options.inPlace) {
this.workingDirectory = process.cwd();
this.backupDirectory = temporaryDirectory.getRandomDirectory('backup');
this.tempDirectory = this.backupDirectory;
this.backupDirectory = temporaryDirectory.path;
this.log.info(
'In place mode is enabled, Stryker will be overriding YOUR files. Find your backup at: %s',
path.relative(process.cwd(), this.backupDirectory),
);
unexpectedExitHandler.registerHandler(this.dispose.bind(this, true));
unexpectedExitHandler.registerHandler(this.dispose.bind(this, /* unexpected */ true));
} else {
this.workingDirectory = temporaryDirectory.getRandomDirectory('sandbox');
this.tempDirectory = this.workingDirectory;
this.workingDirectory = temporaryDirectory.path;
this.log.debug('Creating a sandbox for files in %s', this.workingDirectory);
}
}

public async init(): Promise<void> {
await this.temporaryDirectory.createDirectory(this.tempDirectory);
await this.fillSandbox();
await this.runBuildCommand();
await this.symlinkNodeModulesIfNeeded();
Expand All @@ -81,7 +77,7 @@ export class Sandbox implements Disposable {
}

public originalFileFor(sandboxFileName: string): string {
return path.resolve(sandboxFileName).replace(this.workingDirectory, process.cwd());
return path.resolve(sandboxFileName).replace(path.resolve(this.workingDirectory), process.cwd());
}

private async fillSandbox(): Promise<void> {
Expand Down
17 changes: 8 additions & 9 deletions packages/core/src/utils/file-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@ import fs from 'fs';
import { isErrnoException } from '@stryker-mutator/util';

export const fileUtils = {
deleteDir(dir: string): Promise<void> {
return fs.promises.rm(dir, { recursive: true, force: true });
},

async cleanFolder(folderName: string): Promise<string | undefined> {
/**
* Cleans the dir by creating it.
*/
async cleanDir(dirName: string): Promise<string | undefined> {
try {
await fs.promises.lstat(folderName);
await this.deleteDir(folderName);
return fs.promises.mkdir(folderName, { recursive: true });
await fs.promises.lstat(dirName);
await fs.promises.rm(dirName, { recursive: true, force: true });
return fs.promises.mkdir(dirName, { recursive: true });
} catch {
return fs.promises.mkdir(folderName, { recursive: true });
return fs.promises.mkdir(dirName, { recursive: true });
}
},

Expand Down
8 changes: 0 additions & 8 deletions packages/core/src/utils/object-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,4 @@ export const objectUtils = {
}
});
},

/**
* Creates a random integer number.
* @returns A random integer.
*/
random(): number {
return Math.ceil(Math.random() * 10000000);
},
};
54 changes: 26 additions & 28 deletions packages/core/src/utils/temporary-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,55 @@ import { Logger } from '@stryker-mutator/api/logging';
import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
import { Disposable } from 'typed-inject';

import { fileUtils } from './file-utils.js';
import { objectUtils } from './object-utils.js';

export class TemporaryDirectory implements Disposable {
private readonly temporaryDirectory: string;
private isInitialized = false;
#temporaryDirectory?: string;
public removeDuringDisposal: boolean;

public static readonly inject = tokens(commonTokens.logger, commonTokens.options);
constructor(
private readonly log: Logger,
options: StrykerOptions,
private readonly options: StrykerOptions,
) {
this.temporaryDirectory = path.resolve(options.tempDirName);
this.removeDuringDisposal = Boolean(options.cleanTempDir);
}

public async initialize(): Promise<void> {
this.log.debug('Using temp directory "%s"', this.temporaryDirectory);
await fs.promises.mkdir(this.temporaryDirectory, { recursive: true });
this.isInitialized = true;
const parent = path.resolve(this.options.tempDirName);
await fs.promises.mkdir(parent, { recursive: true });
this.#temporaryDirectory = await fs.promises.mkdtemp(path.join(parent, this.options.inPlace ? 'backup-' : 'sandbox-'));
this.log.debug('Using temp directory "%s"', this.#temporaryDirectory);
}

public getRandomDirectory(prefix: string): string {
return path.resolve(this.temporaryDirectory, `${prefix}${objectUtils.random()}`);
get path() {
if (!this.#temporaryDirectory) {
this.#throwNotInitialized();
}
return this.#temporaryDirectory;
}

/**
* Creates a new random directory with the specified prefix.
* @returns The path to the directory.
*/
public async createDirectory(name: string): Promise<void> {
if (!this.isInitialized) {
throw new Error('initialize() was not called!');
}
await fs.promises.mkdir(path.resolve(this.temporaryDirectory, name), { recursive: true });
#throwNotInitialized(): never {
throw new Error('initialize() was not called!');
}

/**
* Deletes the Stryker-temp directory
*/
public async dispose(): Promise<void> {
if (!this.isInitialized) {
throw new Error('initialize() was not called!');
}
if (this.removeDuringDisposal) {
this.log.debug('Deleting stryker temp directory %s', this.temporaryDirectory);
if (this.removeDuringDisposal && this.#temporaryDirectory) {
this.log.debug('Deleting stryker temp directory %s', this.#temporaryDirectory);
try {
await fileUtils.deleteDir(this.temporaryDirectory);
await fs.promises.rm(this.#temporaryDirectory, { recursive: true, force: true });
} catch {
this.log.info(`Failed to delete stryker temp directory ${this.temporaryDirectory}`);
this.log.info(`Failed to delete stryker temp directory ${this.#temporaryDirectory}`);
}
const lingeringDirectories = await fs.promises.readdir(this.options.tempDirName);
if (!lingeringDirectories.length) {
try {
await fs.promises.rmdir(this.options.tempDirName);
} catch (e) {
// It's not THAT important, maybe another StrykerJS process started in the meantime.
this.log.debug(`Failed to clean temp ${path.basename(this.options.tempDirName)}`, e);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/integration/utils/file-utils.it.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('fileUtils', () => {
const to = path.resolve(os.tmpdir(), 'moveDirectoryRecursiveSyncTo');

afterEach(async () => {
await Promise.all([fileUtils.deleteDir(from), fileUtils.deleteDir(to)]);
await Promise.all([fsPromises.rm(from, { recursive: true, force: true }), fsPromises.rm(to, { recursive: true, force: true })]);
});

it('should override target files', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,23 @@ import { fileUtils } from '../../../src/utils/file-utils.js';

describe(EventRecorderReporter.name, () => {
let sut: StrictReporter;
let cleanFolderStub: sinon.SinonStub;
let cleanDirStub: sinon.SinonStub;
let writeFileStub: sinon.SinonStub;

beforeEach(() => {
cleanFolderStub = sinon.stub(fileUtils, 'cleanFolder');
writeFileStub = sinon.stub(fs.promises, 'writeFile');
cleanDirStub = sinon.stub(fileUtils, 'cleanDir');
});

describe('when constructed with empty options', () => {
describe('and cleanFolder resolves correctly', () => {
beforeEach(() => {
cleanFolderStub.returns(Promise.resolve());
cleanDirStub.returns(Promise.resolve());
sut = testInjector.injector.injectClass(EventRecorderReporter);
});

it('should clean the baseFolder', () => {
expect(fileUtils.cleanFolder).to.have.been.calledWith('reports/mutation/events');
expect(fileUtils.cleanDir).to.have.been.calledWith('reports/mutation/events');
});

const arrangeActAssertEvent = (eventName: keyof Reporter) => {
Expand Down Expand Up @@ -68,7 +68,7 @@ describe(EventRecorderReporter.name, () => {
let expectedError: Error;
beforeEach(() => {
expectedError = new Error('Some error 1');
cleanFolderStub.rejects(expectedError);
cleanDirStub.rejects(expectedError);
sut = testInjector.injector.injectClass(EventRecorderReporter);
});

Expand Down
48 changes: 7 additions & 41 deletions packages/core/test/unit/sandbox/sandbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,11 @@ describe(Sandbox.name, () => {
let unexpectedExitHandlerMock: sinon.SinonStubbedInstance<I<UnexpectedExitHandler>>;
let moveDirectoryRecursiveSyncStub: sinon.SinonStub;
let fsTestDouble: FileSystemTestDouble;
const SANDBOX_WORKING_DIR = path.resolve('.stryker-tmp/sandbox-123');
const BACKUP_DIR = 'backup-123';
const SANDBOX_WORKING_DIR = path.join('.stryker-tmp', 'sandbox-123');

beforeEach(() => {
temporaryDirectoryMock = sinon.createStubInstance(TemporaryDirectory);
temporaryDirectoryMock.getRandomDirectory.withArgs('sandbox').returns(SANDBOX_WORKING_DIR).withArgs('backup').returns(BACKUP_DIR);
sinon.stub(temporaryDirectoryMock, 'path').value(SANDBOX_WORKING_DIR);
symlinkJunctionStub = sinon.stub(fileUtils, 'symlinkJunction');
findNodeModulesListStub = sinon.stub(fileUtils, 'findNodeModulesList');
moveDirectoryRecursiveSyncStub = sinon.stub(fileUtils, 'moveDirectoryRecursiveSync');
Expand Down Expand Up @@ -68,13 +67,6 @@ describe(Sandbox.name, () => {
testInjector.options.inPlace = false;
});

it('should have created a sandbox folder', async () => {
const sut = createSut();
await sut.init();
expect(temporaryDirectoryMock.getRandomDirectory).calledWith('sandbox');
expect(temporaryDirectoryMock.createDirectory).calledWith(SANDBOX_WORKING_DIR);
});

it('should copy regular input files', async () => {
fsTestDouble.files[path.resolve('a', 'main.js')] = 'foo("bar")';
fsTestDouble.files[path.resolve('a', 'b.txt')] = 'b content';
Expand Down Expand Up @@ -113,13 +105,6 @@ describe(Sandbox.name, () => {
testInjector.options.inPlace = true;
});

it('should have created a backup directory', async () => {
const sut = createSut();
await sut.init();
expect(temporaryDirectoryMock.getRandomDirectory).calledWith('backup');
expect(temporaryDirectoryMock.createDirectory).calledWith(BACKUP_DIR);
});

it('should not override the current file if no changes were detected', async () => {
fsTestDouble.files[path.resolve('a', 'b.txt')] = 'b content';
const sut = createSut();
Expand Down Expand Up @@ -150,7 +135,7 @@ describe(Sandbox.name, () => {
fsTestDouble.files[fileName] = originalContent;
const project = new Project(fsTestDouble, { [fileName]: { mutate: true } });
project.files.get(fileName)!.setContent(mutatedContent);
const expectedBackupFileName = path.join(path.join(BACKUP_DIR, 'a'), 'b.js');
const expectedBackupFileName = path.join(path.join(SANDBOX_WORKING_DIR, 'a'), 'b.js');

// Act
const sut = createSut(project);
Expand All @@ -169,7 +154,7 @@ describe(Sandbox.name, () => {
fsTestDouble.files[fileName] = originalContent;
const project = new Project(fsTestDouble, { [fileName]: { mutate: true } });
project.files.get(fileName)!.setContent(mutatedContent);
const expectedBackupFileName = path.join(path.join(BACKUP_DIR, 'a'), 'b.js');
const expectedBackupFileName = path.join(path.join(SANDBOX_WORKING_DIR, 'a'), 'b.js');

// Act
const sut = createSut(project);
Expand Down Expand Up @@ -280,16 +265,16 @@ describe(Sandbox.name, () => {
testInjector.options.inPlace = true;
const sut = createSut();
sut.dispose();
expect(moveDirectoryRecursiveSyncStub).calledWith(BACKUP_DIR, process.cwd());
sinon.assert.calledWithExactly(moveDirectoryRecursiveSyncStub, SANDBOX_WORKING_DIR, process.cwd());
});

it('should recover from the backup dir if stryker exits unexpectedly while inPlace = true', () => {
testInjector.options.inPlace = true;
const errorStub = sinon.stub(console, 'error');
createSut();
unexpectedExitHandlerMock.registerHandler.callArg(0);
expect(moveDirectoryRecursiveSyncStub).calledWith(BACKUP_DIR, process.cwd());
expect(errorStub).calledWith(`Detecting unexpected exit, recovering original files from ${BACKUP_DIR}`);
expect(moveDirectoryRecursiveSyncStub).calledWith(SANDBOX_WORKING_DIR, process.cwd());
expect(errorStub).calledWith(`Detecting unexpected exit, recovering original files from ${SANDBOX_WORKING_DIR}`);
});
});

Expand All @@ -308,25 +293,6 @@ describe(Sandbox.name, () => {
});
});

// describe(Sandbox.prototype.sandboxFileFor.name, () => {
// it('should return the sandbox file if exists', async () => {
// const originalFileName = path.resolve('src/foo.js');
// fsTestDouble.push(new File(originalFileName, ''));
// const sut = createSut();
// await sut.init();
// const actualSandboxFile = sut.sandboxFileFor(originalFileName);
// expect(actualSandboxFile).eq(path.join(SANDBOX_WORKING_DIR, 'src/foo.js'));
// });

// it("should throw when the sandbox file doesn't exists", async () => {
// const notExistingFile = 'src/bar.js';
// fsTestDouble.push(new File(path.resolve('src/foo.js'), ''));
// const sut = createSut();
// await sut.init();
// expect(() => sut.sandboxFileFor(notExistingFile)).throws('Cannot find sandbox file for src/bar.js');
// });
// });

describe(Sandbox.prototype.originalFileFor.name, () => {
it('should remap the file to the original', async () => {
const sut = createSut();
Expand Down
Loading

0 comments on commit d15453e

Please sign in to comment.