Skip to content

Commit

Permalink
feat(hot-reload): implement hot-reload in mocha runner
Browse files Browse the repository at this point in the history
  • Loading branch information
nicojs committed Jan 15, 2022
1 parent ecbe00d commit bfe10fb
Show file tree
Hide file tree
Showing 19 changed files with 345 additions and 215 deletions.
11 changes: 11 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "🚀 Run current file",
"program": "${file}",
"request": "launch",
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${fileDirname}",
"outputCapture": "std",
"type": "pwa-node"
},
{
"type": "node",
"request": "attach",
Expand Down
4 changes: 2 additions & 2 deletions packages/mocha-runner/src/lib-wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'path';

import Mocha from 'mocha';
import Mocha, { type RootHookObject } from 'mocha';
import glob from 'glob';

import { MochaOptions } from '../src-generated/mocha-runner-options';
Expand All @@ -9,7 +9,7 @@ const mochaRoot = path.dirname(require.resolve('mocha/package.json'));

let loadOptions: ((argv?: string[] | string) => Record<string, any> | undefined) | undefined;
let collectFiles: ((options: MochaOptions) => string[]) | undefined;
let handleRequires: ((requires?: string[]) => Promise<any>) | undefined;
let handleRequires: ((requires?: string[]) => Promise<RootHookObject>) | undefined;
let loadRootHooks: ((rootHooks: any) => Promise<any>) | undefined;

try {
Expand Down
6 changes: 4 additions & 2 deletions packages/mocha-runner/src/mocha-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
import { Logger } from '@stryker-mutator/api/logging';
import { PropertyPathBuilder } from '@stryker-mutator/util';

import { RootHookObject } from 'mocha';

import { MochaOptions, MochaRunnerOptions } from '../src-generated/mocha-runner-options';

import { LibWrapper } from './lib-wrapper';
Expand Down Expand Up @@ -37,7 +39,7 @@ export class MochaAdapter {
}
}

public async handleRequires(requires: string[]): Promise<unknown> {
public async handleRequires(requires: string[]): Promise<RootHookObject | undefined> {
this.log.trace('Resolving requires %s', requires);
if (LibWrapper.handleRequires) {
this.log.trace('Using `handleRequires`');
Expand All @@ -47,7 +49,7 @@ export class MochaAdapter {
// `loadRootHooks` made a brief appearance in mocha 8, removed in mocha 8.2
return await LibWrapper.loadRootHooks(rawRootHooks);
} else {
return rawRootHooks.rootHooks;
return (rawRootHooks as { rootHooks: RootHookObject }).rootHooks;
}
}
} else {
Expand Down
123 changes: 57 additions & 66 deletions packages/mocha-runner/src/mocha-test-runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { InstrumenterContext, INSTRUMENTER_CONSTANTS, StrykerOptions } from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
import { I, escapeRegExp, DirectoryRequireCache } from '@stryker-mutator/util';
import { I, escapeRegExp } from '@stryker-mutator/util';

import {
TestRunner,
Expand All @@ -16,7 +16,7 @@ import {
TestRunnerCapabilities,
} from '@stryker-mutator/api/test-runner';

import { MochaOptions } from '../src-generated/mocha-runner-options';
import { Context, RootHookObject, Suite } from 'mocha';

import { StrykerMochaReporter } from './stryker-mocha-reporter';
import { MochaRunnerWithStrykerOptions } from './mocha-runner-with-stryker-options';
Expand All @@ -25,25 +25,23 @@ import { MochaOptionsLoader } from './mocha-options-loader';
import { MochaAdapter } from './mocha-adapter';

export class MochaTestRunner implements TestRunner {
public testFileNames?: string[];
public rootHooks: any;
public mochaOptions!: MochaOptions;
private mocha!: Mocha;
private readonly instrumenterContext: InstrumenterContext;
private originalGrep?: string;
public beforeEach?: (context: Context) => void;

public static inject = tokens(
commonTokens.logger,
commonTokens.options,
pluginTokens.loader,
pluginTokens.mochaAdapter,
pluginTokens.directoryRequireCache,
pluginTokens.globalNamespace
);
constructor(
private readonly log: Logger,
private readonly options: StrykerOptions,
private readonly loader: I<MochaOptionsLoader>,
private readonly mochaAdapter: I<MochaAdapter>,
private readonly requireCache: I<DirectoryRequireCache>,
globalNamespace: typeof INSTRUMENTER_CONSTANTS.NAMESPACE | '__stryker2__'
) {
StrykerMochaReporter.log = log;
Expand All @@ -58,73 +56,77 @@ export class MochaTestRunner implements TestRunner {
}

public async init(): Promise<void> {
this.mochaOptions = this.loader.load(this.options as MochaRunnerWithStrykerOptions);
this.testFileNames = this.mochaAdapter.collectFiles(this.mochaOptions);
if (this.mochaOptions.require) {
if (this.mochaOptions.require.includes('esm')) {
const mochaOptions = this.loader.load(this.options as MochaRunnerWithStrykerOptions);
const testFileNames = this.mochaAdapter.collectFiles(mochaOptions);
let rootHooks: RootHookObject | undefined;
if (mochaOptions.require) {
if (mochaOptions.require.includes('esm')) {
throw new Error(
'Config option "mochaOptions.require" does not support "esm", please use `"testRunnerNodeArgs": ["--require", "esm"]` instead. See https://github.com/stryker-mutator/stryker-js/issues/3014 for more information.'
);
}
this.rootHooks = await this.mochaAdapter.handleRequires(this.mochaOptions.require);
rootHooks = await this.mochaAdapter.handleRequires(mochaOptions.require);
}
this.mocha = this.mochaAdapter.create({
reporter: StrykerMochaReporter as any,
timeout: false as any, // Mocha 5 doesn't support `0`
rootHooks,
});
// @ts-expect-error
this.mocha.cleanReferencesAfterRun(false);
testFileNames.forEach((fileName) => this.mocha.addFile(fileName));

this.setIfDefined(mochaOptions['async-only'], (asyncOnly) => asyncOnly && this.mocha.asyncOnly());
this.setIfDefined(mochaOptions.ui, this.mocha.ui);
this.setIfDefined(mochaOptions.grep, this.mocha.grep);
this.originalGrep = mochaOptions.grep;

// Bind beforeEach, so we can use that for per code coverage in dry run
const self = this;
this.mocha.suite.beforeEach(function (this: Context) {
self.beforeEach?.(this);
});
}

private setIfDefined<T>(value: T | undefined, operation: (input: T) => void) {
if (typeof value !== 'undefined') {
operation.apply(this.mocha, [value]);
}
}

public async dryRun({ coverageAnalysis, disableBail }: DryRunOptions): Promise<DryRunResult> {
// eslint-disable-next-line @typescript-eslint/no-empty-function
let interceptor: (mocha: Mocha) => void = () => {};
if (coverageAnalysis === 'perTest') {
interceptor = (mocha) => {
const self = this;
mocha.suite.beforeEach('StrykerIntercept', function () {
self.instrumenterContext.currentTestId = this.currentTest?.fullTitle();
});
this.beforeEach = (context) => {
this.instrumenterContext.currentTestId = context.currentTest?.fullTitle();
};
}
const runResult = await this.run(interceptor, disableBail);
const runResult = await this.run(disableBail);
if (runResult.status === DryRunStatus.Complete && coverageAnalysis !== 'off') {
runResult.mutantCoverage = this.instrumenterContext.mutantCoverage;
}
delete this.beforeEach;
return runResult;
}

public async mutantRun({ activeMutant, testFilter, disableBail, hitLimit }: MutantRunOptions): Promise<MutantRunResult> {
this.instrumenterContext.activeMutant = activeMutant.id;
this.instrumenterContext.hitLimit = hitLimit;
this.instrumenterContext.hitCount = hitLimit ? 0 : undefined;
// eslint-disable-next-line @typescript-eslint/no-empty-function
let intercept: (mocha: Mocha) => void = () => {};
if (testFilter) {
const metaRegExp = testFilter.map((testId) => `(${escapeRegExp(testId)})`).join('|');
const regex = new RegExp(metaRegExp);
intercept = (mocha) => {
mocha.grep(regex);
};
this.mocha.grep(regex);
} else {
this.setIfDefined(this.originalGrep, this.mocha.grep);
}
const dryRunResult = await this.run(intercept, disableBail);
const dryRunResult = await this.run(disableBail);
return toMutantRunResult(dryRunResult, true);
}

public async run(intercept: (mocha: Mocha) => void, disableBail: boolean): Promise<DryRunResult> {
this.requireCache.clear();
const mocha = this.mochaAdapter.create({
reporter: StrykerMochaReporter as any,
bail: !disableBail,
timeout: false as any, // Mocha 5 doesn't support `0`
rootHooks: this.rootHooks,
} as Mocha.MochaOptions);
this.configure(mocha);
intercept(mocha);
this.addFiles(mocha);
public async run(disableBail: boolean): Promise<DryRunResult> {
setBail(!disableBail, this.mocha.suite);
try {
await this.runMocha(mocha);
// Call `requireCache.record` before `mocha.dispose`.
// `Mocha.dispose` already deletes test files from require cache, but its important that they are recorded before that.
this.requireCache.record();
if ((mocha as any).dispose) {
// Since mocha 7.2
(mocha as any).dispose();
}
await this.runMocha();
const reporter = StrykerMochaReporter.currentInstance;
if (reporter) {
const timeoutResult = determineHitLimitReached(this.instrumenterContext.hitCount, this.instrumenterContext.hitLimit);
Expand All @@ -150,31 +152,20 @@ export class MochaTestRunner implements TestRunner {
status: DryRunStatus.Error,
};
}
}

private runMocha(mocha: Mocha): Promise<void> {
return new Promise<void>((res) => {
mocha.run(() => res());
});
function setBail(bail: boolean, suite: Suite) {
suite.bail(bail);
suite.suites.forEach((childSuite) => setBail(bail, childSuite));
}
}

private addFiles(mocha: Mocha) {
this.testFileNames?.forEach((fileName) => {
mocha.addFile(fileName);
});
public async dispose(): Promise<void> {
this.mocha.dispose();
}

private configure(mocha: Mocha) {
const options = this.mochaOptions;

function setIfDefined<T>(value: T | undefined, operation: (input: T) => void) {
if (typeof value !== 'undefined') {
operation.apply(mocha, [value]);
}
}

setIfDefined(options['async-only'], (asyncOnly) => asyncOnly && mocha.asyncOnly());
setIfDefined(options.ui, mocha.ui);
setIfDefined(options.grep, mocha.grep);
private runMocha(): Promise<void> {
return new Promise<void>((res) => {
this.mocha.run(() => res());
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,18 @@ import { testInjector } from '@stryker-mutator/test-helpers';

import { MochaOptionsLoader } from '../../src/mocha-options-loader';
import { MochaRunnerWithStrykerOptions } from '../../src/mocha-runner-with-stryker-options';
import { createMochaTestRunnerFactory } from '../../src';
import { resolveTestResource } from '../helpers/resolve-test-resource';
import { MochaAdapter } from '../../src/mocha-adapter';

describe('Mocha 6 file resolving integration', () => {
let options: MochaRunnerWithStrykerOptions;

beforeEach(() => {
options = testInjector.options as MochaRunnerWithStrykerOptions;
options.mochaOptions = {};
});

it('should resolve test files while respecting "files", "spec", "extension" and "exclude" properties', () => {
const configLoader = createConfigLoader();
process.chdir(resolveTestDir());
options.mochaOptions = configLoader.load(options);
const testRunner = createTestRunner();
testRunner.init();
expect((testRunner as any).testFileNames).deep.eq([
const options: MochaRunnerWithStrykerOptions = { ...testInjector.options, mochaOptions: {} };
const mochaOptions = configLoader.load(options);
const mochaAdapter = testInjector.injector.injectClass(MochaAdapter);
const files = mochaAdapter.collectFiles(mochaOptions);
expect(files).deep.eq([
resolveTestDir('helpers/1.ts'),
resolveTestDir('helpers/2.js'),
resolveTestDir('specs/3.js'),
Expand All @@ -31,11 +25,6 @@ describe('Mocha 6 file resolving integration', () => {
function createConfigLoader() {
return testInjector.injector.injectClass(MochaOptionsLoader);
}

function createTestRunner() {
return testInjector.injector.injectFunction(createMochaTestRunnerFactory('__stryker2__'));
}

function resolveTestDir(fileName = '.') {
return resolveTestResource('file-resolving', fileName);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ import { resolveTestResource } from '../helpers/resolve-test-resource';
describe('Running a project with root hooks', () => {
let sut: MochaTestRunner;

beforeEach(async () => {
before(async () => {
process.chdir(resolveTestResource('parallel-with-root-hooks-sample'));
sut = testInjector.injector.injectFunction(createMochaTestRunnerFactory('__stryker2__'));
await sut.init();
});

after(async () => {
await sut.dispose();
});

it('should have run the root hooks', async () => {
const result = await sut.dryRun(factory.dryRunOptions({}));
assertions.expectCompleted(result);
Expand Down
12 changes: 1 addition & 11 deletions packages/mocha-runner/test/integration/qunit-sample.it.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,6 @@ describe('QUnit sample', () => {
'Math should be able to recognize a negative number',
'Math should be able to recognize that 0 is not a negative number',
]);
});

it('should not run tests when not configured with "qunit" ui', async () => {
testInjector.options.mochaOptions = createMochaOptions({
files: [resolveTestResource('qunit-sample', 'MyMathSpec.js')],
});
const sut = createSut();
await sut.init();
const actualResult = await sut.dryRun(factory.dryRunOptions());
assertions.expectCompleted(actualResult);
expect(actualResult.tests).lengthOf(0);
await sut.dispose();
});
});
11 changes: 8 additions & 3 deletions packages/mocha-runner/test/integration/regession.it.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ import { FailedTestResult, TestStatus } from '@stryker-mutator/api/test-runner';
import { assertions, factory, testInjector } from '@stryker-mutator/test-helpers';
import { expect } from 'chai';

import { createMochaTestRunnerFactory } from '../../src';
import { createMochaTestRunnerFactory, MochaTestRunner } from '../../src';
import { MochaRunnerWithStrykerOptions } from '../../src/mocha-runner-with-stryker-options';
import { resolveTestResource } from '../helpers/resolve-test-resource';

describe('regression integration tests', () => {
let options: MochaRunnerWithStrykerOptions;
let sut: MochaTestRunner;

beforeEach(() => {
options = testInjector.options as MochaRunnerWithStrykerOptions;
options.mochaOptions = { 'no-config': true };
});

afterEach(async () => {
await sut.dispose();
});

describe('issue #2720', () => {
beforeEach(async () => {
process.chdir(resolveTestResource('regression', 'issue-2720'));
Expand All @@ -22,7 +27,7 @@ describe('regression integration tests', () => {
it('should have report correct failing test when "beforeEach" fails', async () => {
// Arrange
options.mochaOptions.spec = ['failing-before-each'];
const sut = testInjector.injector.injectFunction(createMochaTestRunnerFactory('__stryker2__'));
sut = testInjector.injector.injectFunction(createMochaTestRunnerFactory('__stryker2__'));
await sut.init();

// Act
Expand All @@ -42,7 +47,7 @@ describe('regression integration tests', () => {
it('should have report correct failing test when "afterEach" fails', async () => {
// Arrange
options.mochaOptions.spec = ['failing-after-each'];
const sut = testInjector.injector.injectFunction(createMochaTestRunnerFactory('__stryker2__'));
sut = testInjector.injector.injectFunction(createMochaTestRunnerFactory('__stryker2__'));
await sut.init();

// Act
Expand Down
Loading

0 comments on commit bfe10fb

Please sign in to comment.