From bfe10fba17c0f815f53b4ef2c1dfff5ee47eb2e4 Mon Sep 17 00:00:00 2001 From: Nico Jansen Date: Sat, 15 Jan 2022 21:46:02 +0100 Subject: [PATCH] feat(hot-reload): implement hot-reload in mocha runner --- .vscode/launch.json | 11 + packages/mocha-runner/src/lib-wrapper.ts | 4 +- packages/mocha-runner/src/mocha-adapter.ts | 6 +- .../mocha-runner/src/mocha-test-runner.ts | 123 ++++++------ .../mocha-file-resolving.it.spec.ts | 23 +-- .../project-with-root-hooks.it.spec.ts | 6 +- .../test/integration/qunit-sample.it.spec.ts | 12 +- .../test/integration/regession.it.spec.ts | 11 +- .../sample-project-instrumented.it.spec.ts | 14 +- .../integration/sample-project.it.spec.ts | 36 ++-- .../timeout-on-infinite-loop.it.spec.ts | 6 +- packages/mocha-runner/test/setup.ts | 7 +- .../test/unit/mocha-test-runner.spec.ts | 189 ++++++++++-------- .../testResources/esm-project/.mocharc.json | 3 + .../testResources/esm-project/package.json | 3 + .../testResources/esm-project/run-mocha.js | 21 ++ .../testResources/esm-project/src/my-math.js | 29 +++ .../esm-project/src/my-math.spec.js | 54 +++++ packages/mocha-runner/tsconfig.src.json | 2 +- 19 files changed, 345 insertions(+), 215 deletions(-) create mode 100644 packages/mocha-runner/testResources/esm-project/.mocharc.json create mode 100644 packages/mocha-runner/testResources/esm-project/package.json create mode 100644 packages/mocha-runner/testResources/esm-project/run-mocha.js create mode 100644 packages/mocha-runner/testResources/esm-project/src/my-math.js create mode 100644 packages/mocha-runner/testResources/esm-project/src/my-math.spec.js diff --git a/.vscode/launch.json b/.vscode/launch.json index 701aa9df9b..3d02ad5dba 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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": [ + "/**" + ], + "cwd": "${fileDirname}", + "outputCapture": "std", + "type": "pwa-node" + }, { "type": "node", "request": "attach", diff --git a/packages/mocha-runner/src/lib-wrapper.ts b/packages/mocha-runner/src/lib-wrapper.ts index f91d76c686..ee44139cb7 100644 --- a/packages/mocha-runner/src/lib-wrapper.ts +++ b/packages/mocha-runner/src/lib-wrapper.ts @@ -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'; @@ -9,7 +9,7 @@ const mochaRoot = path.dirname(require.resolve('mocha/package.json')); let loadOptions: ((argv?: string[] | string) => Record | undefined) | undefined; let collectFiles: ((options: MochaOptions) => string[]) | undefined; -let handleRequires: ((requires?: string[]) => Promise) | undefined; +let handleRequires: ((requires?: string[]) => Promise) | undefined; let loadRootHooks: ((rootHooks: any) => Promise) | undefined; try { diff --git a/packages/mocha-runner/src/mocha-adapter.ts b/packages/mocha-runner/src/mocha-adapter.ts index cedf371900..050806f42a 100644 --- a/packages/mocha-runner/src/mocha-adapter.ts +++ b/packages/mocha-runner/src/mocha-adapter.ts @@ -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'; @@ -37,7 +39,7 @@ export class MochaAdapter { } } - public async handleRequires(requires: string[]): Promise { + public async handleRequires(requires: string[]): Promise { this.log.trace('Resolving requires %s', requires); if (LibWrapper.handleRequires) { this.log.trace('Using `handleRequires`'); @@ -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 { diff --git a/packages/mocha-runner/src/mocha-test-runner.ts b/packages/mocha-runner/src/mocha-test-runner.ts index 1648800d5d..ad6c94b6af 100644 --- a/packages/mocha-runner/src/mocha-test-runner.ts +++ b/packages/mocha-runner/src/mocha-test-runner.ts @@ -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, @@ -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'; @@ -25,17 +25,16 @@ 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( @@ -43,7 +42,6 @@ export class MochaTestRunner implements TestRunner { private readonly options: StrykerOptions, private readonly loader: I, private readonly mochaAdapter: I, - private readonly requireCache: I, globalNamespace: typeof INSTRUMENTER_CONSTANTS.NAMESPACE | '__stryker2__' ) { StrykerMochaReporter.log = log; @@ -58,33 +56,55 @@ export class MochaTestRunner implements TestRunner { } public async init(): Promise { - 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(value: T | undefined, operation: (input: T) => void) { + if (typeof value !== 'undefined') { + operation.apply(this.mocha, [value]); } } public async dryRun({ coverageAnalysis, disableBail }: DryRunOptions): Promise { - // 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; } @@ -92,39 +112,21 @@ export class MochaTestRunner implements TestRunner { 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 { - 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 { + 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); @@ -150,31 +152,20 @@ export class MochaTestRunner implements TestRunner { status: DryRunStatus.Error, }; } - } - private runMocha(mocha: Mocha): Promise { - return new Promise((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 { + this.mocha.dispose(); } - private configure(mocha: Mocha) { - const options = this.mochaOptions; - - function setIfDefined(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 { + return new Promise((res) => { + this.mocha.run(() => res()); + }); } } diff --git a/packages/mocha-runner/test/integration/mocha-file-resolving.it.spec.ts b/packages/mocha-runner/test/integration/mocha-file-resolving.it.spec.ts index 436f947329..2c845b0490 100644 --- a/packages/mocha-runner/test/integration/mocha-file-resolving.it.spec.ts +++ b/packages/mocha-runner/test/integration/mocha-file-resolving.it.spec.ts @@ -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'), @@ -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); } diff --git a/packages/mocha-runner/test/integration/project-with-root-hooks.it.spec.ts b/packages/mocha-runner/test/integration/project-with-root-hooks.it.spec.ts index 2ae5282b62..ce3e94d0f9 100644 --- a/packages/mocha-runner/test/integration/project-with-root-hooks.it.spec.ts +++ b/packages/mocha-runner/test/integration/project-with-root-hooks.it.spec.ts @@ -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); diff --git a/packages/mocha-runner/test/integration/qunit-sample.it.spec.ts b/packages/mocha-runner/test/integration/qunit-sample.it.spec.ts index 63786cc540..2e1c13aeb3 100644 --- a/packages/mocha-runner/test/integration/qunit-sample.it.spec.ts +++ b/packages/mocha-runner/test/integration/qunit-sample.it.spec.ts @@ -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(); }); }); diff --git a/packages/mocha-runner/test/integration/regession.it.spec.ts b/packages/mocha-runner/test/integration/regession.it.spec.ts index 18316cae78..188049bbce 100644 --- a/packages/mocha-runner/test/integration/regession.it.spec.ts +++ b/packages/mocha-runner/test/integration/regession.it.spec.ts @@ -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')); @@ -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 @@ -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 diff --git a/packages/mocha-runner/test/integration/sample-project-instrumented.it.spec.ts b/packages/mocha-runner/test/integration/sample-project-instrumented.it.spec.ts index 7f3d28f3e3..a651845c33 100644 --- a/packages/mocha-runner/test/integration/sample-project-instrumented.it.spec.ts +++ b/packages/mocha-runner/test/integration/sample-project-instrumented.it.spec.ts @@ -9,7 +9,8 @@ import { resolveTestResource } from '../helpers/resolve-test-resource'; describe('Running an instrumented project', () => { let sut: MochaTestRunner; - beforeEach(async () => { + // Not `beforeEach`, as spec code can only be loaded once + before(async () => { const spec = [ resolveTestResource('sample-project-instrumented', 'MyMath.js'), resolveTestResource('sample-project-instrumented', 'MyMathSpec.js'), @@ -19,6 +20,10 @@ describe('Running an instrumented project', () => { await sut.init(); }); + after(async () => { + await sut.dispose(); + }); + describe('dryRun', () => { it('should report perTest mutantCoverage when coverage analysis is "perTest"', async () => { const result = await sut.dryRun(factory.dryRunOptions({ coverageAnalysis: 'perTest' })); @@ -92,7 +97,12 @@ describe('Running an instrumented project', () => { '15': 1, }, }; - expect(result.mutantCoverage).deep.eq(expectedMutantCoverage); + try { + expect(result.mutantCoverage).deep.eq(expectedMutantCoverage); + } catch { + delete expectedMutantCoverage.static['0']; + expect(result.mutantCoverage).deep.eq(expectedMutantCoverage); + } }); it('should not report mutantCoverage when coverage analysis is "off"', async () => { diff --git a/packages/mocha-runner/test/integration/sample-project.it.spec.ts b/packages/mocha-runner/test/integration/sample-project.it.spec.ts index e88dcb2b85..758b049609 100644 --- a/packages/mocha-runner/test/integration/sample-project.it.spec.ts +++ b/packages/mocha-runner/test/integration/sample-project.it.spec.ts @@ -13,20 +13,22 @@ const countFailed = (runResult: CompleteDryRunResult) => countTests(runResult, ( describe('Running a sample project', () => { let sut: MochaTestRunner; - let spec: string[]; function createSut() { return testInjector.injector.injectFunction(createMochaTestRunnerFactory('__stryker2__')); } describe('when tests pass', () => { - beforeEach(() => { - spec = [resolveTestResource('sample-project', 'MyMath.js'), resolveTestResource('sample-project', 'MyMathSpec.js')]; + before(async () => { + const spec = [resolveTestResource('sample-project', 'MyMathSpec.js')]; testInjector.options.mochaOptions = createMochaOptions({ spec }); sut = createSut(); - return sut.init(); + await sut.init(); }); + after(async () => { + await sut.dispose(); + }); it('should report completed tests', async () => { const runResult = await sut.dryRun(factory.dryRunOptions()); assertions.expectCompleted(runResult); @@ -43,29 +45,16 @@ describe('Running a sample project', () => { }); }); - describe('with an error in an un-included input file', () => { - beforeEach(() => { - spec = [resolveTestResource('sample-project', 'MyMath.js'), resolveTestResource('sample-project', 'MyMathSpec.js')]; - testInjector.options.mochaOptions = createMochaOptions({ - files: spec, - }); - sut = createSut(); - return sut.init(); - }); - - it('should report completed tests without errors', async () => { - const runResult = await sut.dryRun(factory.dryRunOptions()); - assertions.expectCompleted(runResult); - }); - }); - describe('with multiple failed tests', () => { before(() => { - spec = [resolveTestResource('sample-project', 'MyMath.js'), resolveTestResource('sample-project', 'MyMathFailedSpec.js')]; + const spec = [resolveTestResource('sample-project', 'MyMathFailedSpec.js')]; testInjector.options.mochaOptions = createMochaOptions({ spec }); sut = createSut(); return sut.init(); }); + after(async () => { + await sut.dispose(); + }); it('should only report the first failure (bail)', async () => { const runResult = await sut.dryRun(factory.dryRunOptions()); @@ -82,12 +71,15 @@ describe('Running a sample project', () => { describe('when no tests are executed', () => { beforeEach(() => { - spec = [resolveTestResource('sample-project', 'MyMath.js')]; + const spec = [resolveTestResource('sample-project', 'MyMath.js')]; testInjector.options.mochaOptions = createMochaOptions({ spec }); sut = createSut(); return sut.init(); }); + after(async () => { + await sut.dispose(); + }); it('should report no completed tests', async () => { const runResult = await sut.dryRun(factory.dryRunOptions()); assertions.expectCompleted(runResult); diff --git a/packages/mocha-runner/test/integration/timeout-on-infinite-loop.it.spec.ts b/packages/mocha-runner/test/integration/timeout-on-infinite-loop.it.spec.ts index b3f7bf86bf..b5b26d9e17 100644 --- a/packages/mocha-runner/test/integration/timeout-on-infinite-loop.it.spec.ts +++ b/packages/mocha-runner/test/integration/timeout-on-infinite-loop.it.spec.ts @@ -8,7 +8,7 @@ import { resolveTestResource } from '../helpers/resolve-test-resource'; describe('Infinite loop', () => { let sut: MochaTestRunner; - beforeEach(async () => { + before(async () => { const spec = [ resolveTestResource('infinite-loop-instrumented', 'infinite-loop.spec.js'), resolveTestResource('infinite-loop', 'infinite-loop.spec.js'), @@ -18,6 +18,10 @@ describe('Infinite loop', () => { await sut.init(); }); + after(async () => { + await sut.dispose(); + }); + it('should be able to recover using a hit counter', async () => { // Arrange const options = factory.mutantRunOptions({ diff --git a/packages/mocha-runner/test/setup.ts b/packages/mocha-runner/test/setup.ts index 1219ed8838..d181460bd1 100644 --- a/packages/mocha-runner/test/setup.ts +++ b/packages/mocha-runner/test/setup.ts @@ -22,6 +22,11 @@ export const mochaHooks = { StrykerMochaReporter.currentInstance = undefined; delete global.__stryker2__?.activeMutant; delete global.__stryker2__?.currentTestId; - delete global.__stryker2__?.mutantCoverage; + if (global.__stryker2__?.mutantCoverage?.perTest) { + global.__stryker2__.mutantCoverage.perTest = {}; + } + if (global.__stryker2__?.mutantCoverage?.static) { + global.__stryker2__.mutantCoverage.static = {}; + } }, }; diff --git a/packages/mocha-runner/test/unit/mocha-test-runner.spec.ts b/packages/mocha-runner/test/unit/mocha-test-runner.spec.ts index e070d91b7a..ccd699b7be 100644 --- a/packages/mocha-runner/test/unit/mocha-test-runner.spec.ts +++ b/packages/mocha-runner/test/unit/mocha-test-runner.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import Mocha from 'mocha'; import { testInjector, factory, assertions } from '@stryker-mutator/test-helpers'; import sinon from 'sinon'; -import { KilledMutantRunResult, MutantRunStatus } from '@stryker-mutator/api/test-runner'; +import { KilledMutantRunResult, MutantRunStatus, TestRunnerCapabilities } from '@stryker-mutator/api/test-runner'; import { DirectoryRequireCache } from '@stryker-mutator/util'; import { MochaTestRunner } from '../../src/mocha-test-runner'; @@ -18,6 +18,7 @@ describe(MochaTestRunner.name, () => { let mochaAdapterMock: sinon.SinonStubbedInstance; let mochaOptionsLoaderMock: sinon.SinonStubbedInstance; let reporterMock: sinon.SinonStubbedInstance; + let testFileNames: string[]; beforeEach(() => { reporterMock = sinon.createStubInstance(StrykerMochaReporter); @@ -27,7 +28,10 @@ describe(MochaTestRunner.name, () => { mochaOptionsLoaderMock = sinon.createStubInstance(MochaOptionsLoader); mocha = sinon.createStubInstance(Mocha) as any; mocha.suite = sinon.createStubInstance(Mocha.Suite) as any; + mocha.suite.suites = []; mochaAdapterMock.create.returns(mocha as unknown as Mocha); + testFileNames = []; + mochaAdapterMock.collectFiles.returns(testFileNames); }); afterEach(() => { @@ -51,6 +55,13 @@ describe(MochaTestRunner.name, () => { }); }); + describe('capabilities', () => { + it('should communicate reloadEnvironment=false', async () => { + const expectedCapabilities: TestRunnerCapabilities = { reloadEnvironment: false }; + expect(await createSut().capabilities()).deep.eq(expectedCapabilities); + }); + }); + describe(MochaTestRunner.prototype.init.name, () => { let sut: MochaTestRunner; beforeEach(() => { @@ -64,15 +75,16 @@ describe(MochaTestRunner.name, () => { }); it('should collect the files', async () => { - const expectedTestFileNames = ['foo.js', 'foo.spec.js']; + testFileNames.push('foo.js', 'foo.spec.js'); const mochaOptions = Object.freeze(createMochaOptions()); mochaOptionsLoaderMock.load.returns(mochaOptions); - mochaAdapterMock.collectFiles.returns(expectedTestFileNames); await sut.init(); expect(mochaAdapterMock.collectFiles).calledWithExactly(mochaOptions); - expect(sut.testFileNames).eq(expectedTestFileNames); + testFileNames.forEach((fileName) => { + expect(mocha.addFile).calledWith(fileName); + }); }); it('should not handle requires when there are no `requires`', async () => { @@ -90,7 +102,8 @@ describe(MochaTestRunner.name, () => { await sut.init(); - expect(sut.rootHooks).eq(expectedRootHooks); + const expectedMochaOptions: Mocha.MochaOptions = { rootHooks: expectedRootHooks }; + expect(mochaAdapterMock.create).calledWith(sinon.match(expectedMochaOptions)); }); it('should reject when requires contains "esm" (see #3014)', async () => { @@ -100,29 +113,22 @@ describe(MochaTestRunner.name, () => { '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.' ); }); - }); - - describe(MochaTestRunner.prototype.dryRun.name, () => { - let sut: MochaTestRunner; - let testFileNames: string[]; - beforeEach(() => { - testFileNames = []; - sut = createSut(); - sut.testFileNames = testFileNames; - sut.mochaOptions = {}; - }); it('should pass along supported options to mocha', async () => { // Arrange - testFileNames.push('foo.js', 'bar.js', 'foo2.js'); - sut.mochaOptions['async-only'] = true; - sut.mochaOptions.grep = 'grepme'; - sut.mochaOptions.opts = 'opts'; - sut.mochaOptions.require = []; - sut.mochaOptions.ui = 'exports'; + const mochaOptions = Object.freeze( + createMochaOptions({ + 'async-only': true, + grep: 'grepme', + opts: 'opts', + require: [], + ui: 'exports', + }) + ); + mochaOptionsLoaderMock.load.returns(mochaOptions); // Act - await actDryRun(); + await sut.init(); // Assert expect(mocha.asyncOnly).called; @@ -131,59 +137,69 @@ describe(MochaTestRunner.name, () => { }); it('should force timeout off', async () => { - await actDryRun(); + mochaOptionsLoaderMock.load.returns({}); + await sut.init(); expect(mochaAdapterMock.create).calledWithMatch({ timeout: false }); }); - it('should set bail to true when disableBail is false', async () => { - await actDryRun(factory.dryRunOptions({ disableBail: false })); - expect(mochaAdapterMock.create).calledWithMatch({ bail: true }); - }); + it('should not set asyncOnly if asyncOnly is false', async () => { + // Arrange + const mochaOptions = Object.freeze( + createMochaOptions({ + 'async-only': false, + }) + ); + mochaOptionsLoaderMock.load.returns(mochaOptions); - it('should set bail to false when disableBail is true', async () => { - await actDryRun(factory.dryRunOptions({ disableBail: true })); - expect(mochaAdapterMock.create).calledWithMatch({ bail: false }); + // Act + await sut.init(); + expect(mocha.asyncOnly).not.called; }); + }); - it("should don't set asyncOnly if asyncOnly is false", async () => { - sut.mochaOptions['async-only'] = false; - await actDryRun(); - expect(mocha.asyncOnly).not.called; + describe(MochaTestRunner.prototype.dryRun.name, () => { + let sut: MochaTestRunner; + beforeEach(async () => { + mochaOptionsLoaderMock.load.returns({}); + sut = createSut(); + await sut.init(); }); - it('should pass rootHooks to the mocha instance', async () => { - // eslint-disable-next-line @typescript-eslint/no-empty-function - const rootHooks = { beforeEach() {} }; - sut.rootHooks = rootHooks; - await actDryRun(); - expect(mochaAdapterMock.create).calledWithMatch({ rootHooks }); + it('should set bail to true when disableBail is false', async () => { + const childSuite = sinon.createStubInstance(Mocha.Suite); + mocha.suite.suites.push(childSuite); + childSuite.suites = []; + await actDryRun(factory.dryRunOptions({ disableBail: false })); + expect(mocha.suite.bail).calledWith(true); + expect(childSuite.bail).calledWith(true); }); - it('should add collected files ', async () => { - sut.testFileNames!.push('foo.js', 'bar.js', 'foo2.js'); - await actDryRun(); - expect(mocha.addFile).calledThrice; - expect(mocha.addFile).calledWith('foo.js'); - expect(mocha.addFile).calledWith('foo2.js'); - expect(mocha.addFile).calledWith('bar.js'); + it('should set bail to false when disableBail is true', async () => { + const childSuite = sinon.createStubInstance(Mocha.Suite); + mocha.suite.suites.push(childSuite); + childSuite.suites = []; + await actDryRun(factory.dryRunOptions({ disableBail: true })); + expect(mocha.suite.bail).calledWith(false); + expect(childSuite.bail).calledWith(false); }); it('should add a beforeEach hook if coverage analysis is "perTest"', async () => { - testFileNames.push(''); - await actDryRun(factory.dryRunOptions({ coverageAnalysis: 'perTest' })); - expect(mocha.suite.beforeEach).calledWithMatch('StrykerIntercept', sinon.match.func); - mocha.suite.beforeEach.callArgOnWith(1, { currentTest: { fullTitle: () => 'foo should be bar' } }); + const runPromise = sut.dryRun(factory.dryRunOptions({ coverageAnalysis: 'perTest' })); + sut.beforeEach!({ currentTest: { fullTitle: () => 'foo should be bar' } } as Mocha.Context); + mocha.run.callArg(0); + await runPromise; + expect(sut.beforeEach).undefined; expect(global.__stryker2__?.currentTestId).eq('foo should be bar'); }); it('should not add a beforeEach hook if coverage analysis isn\'t "perTest"', async () => { - testFileNames.push(''); - await actDryRun(factory.dryRunOptions({ coverageAnalysis: 'all' })); - expect(mocha.suite.beforeEach).not.called; + const runPromise = sut.dryRun(factory.dryRunOptions({ coverageAnalysis: 'all' })); + expect(sut.beforeEach).undefined; + mocha.run.callArg(0); + await runPromise; }); it('should collect mutant coverage', async () => { - testFileNames.push(''); StrykerMochaReporter.currentInstance = reporterMock; reporterMock.tests = []; global.__stryker2__!.mutantCoverage = factory.mutantCoverage({ static: { 1: 2 } }); @@ -193,7 +209,6 @@ describe(MochaTestRunner.name, () => { }); it('should not collect mutant coverage if coverageAnalysis is "off"', async () => { - testFileNames.push(''); StrykerMochaReporter.currentInstance = reporterMock; reporterMock.tests = []; global.__stryker2__!.mutantCoverage = factory.mutantCoverage({ static: { 1: 2 } }); @@ -203,7 +218,6 @@ describe(MochaTestRunner.name, () => { }); it('should result in the reported tests', async () => { - testFileNames.push(''); const expectedTests = [factory.successTestResult(), factory.failedTestResult()]; StrykerMochaReporter.currentInstance = reporterMock; reporterMock.tests = expectedTests; @@ -213,30 +227,11 @@ describe(MochaTestRunner.name, () => { }); it("should result an error if the StrykerMochaReporter isn't set correctly", async () => { - testFileNames.push(''); const result = await actDryRun(factory.dryRunOptions({ coverageAnalysis: 'off' })); assertions.expectErrored(result); expect(result.errorMessage).eq("Mocha didn't instantiate the StrykerMochaReporter correctly. Test result cannot be reported."); }); - it('should collect and purge the requireCache between runs', async () => { - // Arrange - testFileNames.push(''); - - // Act - await actDryRun(factory.dryRunOptions()); - - // Assert - expect(directoryRequireCacheMock.clear).called; - expect(directoryRequireCacheMock.record).called; - expect(directoryRequireCacheMock.clear).calledBefore(directoryRequireCacheMock.record); - }); - - it('should dispose of mocha when it supports it', async () => { - await actDryRun(); - expect(mocha.dispose).called; - }); - async function actDryRun(options = factory.dryRunOptions()) { mocha.run.onFirstCall().callsArg(0); return sut.dryRun(options); @@ -245,10 +240,10 @@ describe(MochaTestRunner.name, () => { describe(MochaTestRunner.prototype.mutantRun.name, () => { let sut: MochaTestRunner; - beforeEach(() => { + beforeEach(async () => { + mochaOptionsLoaderMock.load.returns({}); sut = createSut(); - sut.testFileNames = []; - sut.mochaOptions = {}; + await sut.init(); StrykerMochaReporter.currentInstance = reporterMock; }); @@ -257,14 +252,22 @@ describe(MochaTestRunner.name, () => { expect(global.__stryker2__?.activeMutant).eq('42'); }); - it('should set bail to false when disableBail is true', async () => { - await actMutantRun(factory.mutantRunOptions({ disableBail: true })); - expect(mochaAdapterMock.create).calledWithMatch({ bail: false }); - }); - it('should set bail to true when disableBail is false', async () => { + const childSuite = sinon.createStubInstance(Mocha.Suite); + mocha.suite.suites.push(childSuite); + childSuite.suites = []; await actMutantRun(factory.mutantRunOptions({ disableBail: false })); - expect(mochaAdapterMock.create).calledWithMatch({ bail: true }); + expect(mocha.suite.bail).calledWith(true); + expect(childSuite.bail).calledWith(true); + }); + + it('should set bail to false when disableBail is true', async () => { + const childSuite = sinon.createStubInstance(Mocha.Suite); + mocha.suite.suites.push(childSuite); + childSuite.suites = []; + await actMutantRun(factory.mutantRunOptions({ disableBail: true })); + expect(mocha.suite.bail).calledWith(false); + expect(childSuite.bail).calledWith(false); }); it('should use `grep` to when the test filter is specified', async () => { @@ -318,4 +321,18 @@ describe(MochaTestRunner.name, () => { return result; } }); + + describe(MochaTestRunner.prototype.dispose.name, () => { + let sut: MochaTestRunner; + beforeEach(async () => { + mochaOptionsLoaderMock.load.returns({}); + sut = createSut(); + await sut.init(); + }); + + it('should dispose of mocha', async () => { + await sut.dispose(); + expect(mocha.dispose).called; + }); + }); }); diff --git a/packages/mocha-runner/testResources/esm-project/.mocharc.json b/packages/mocha-runner/testResources/esm-project/.mocharc.json new file mode 100644 index 0000000000..74f977c48a --- /dev/null +++ b/packages/mocha-runner/testResources/esm-project/.mocharc.json @@ -0,0 +1,3 @@ +{ + "spec": ["src/*.spec.js"] +} diff --git a/packages/mocha-runner/testResources/esm-project/package.json b/packages/mocha-runner/testResources/esm-project/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/packages/mocha-runner/testResources/esm-project/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/mocha-runner/testResources/esm-project/run-mocha.js b/packages/mocha-runner/testResources/esm-project/run-mocha.js new file mode 100644 index 0000000000..59cf23e404 --- /dev/null +++ b/packages/mocha-runner/testResources/esm-project/run-mocha.js @@ -0,0 +1,21 @@ +// @ts-check +import Mocha from 'mocha'; +import { promisify } from 'util'; +import { loadRc } from 'mocha/lib/cli/options.js'; +import path from 'path'; +import { fileURLToPath } from 'node:url'; + +const m = new Mocha(); + +m.cleanReferencesAfterRun(false); + +m.addFile(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'src', 'my-math.spec.js')); +const run = promisify(m.run.bind(m)) + +async function main(){ + await m.loadFilesAsync(); + await run(); + await run(); +} + +main().catch(console.error); diff --git a/packages/mocha-runner/testResources/esm-project/src/my-math.js b/packages/mocha-runner/testResources/esm-project/src/my-math.js new file mode 100644 index 0000000000..f28651a467 --- /dev/null +++ b/packages/mocha-runner/testResources/esm-project/src/my-math.js @@ -0,0 +1,29 @@ +'use strict'; + +const pi = 3 + .14; + +class MyMath { + constructor() { + this.pi = pi; + } + add(num1, num2) { + return num1 + num2; + } + addOne(number) { + number++; + return number; + } + negate(number) { + return -number; + } + isNegativeNumber(number) { + let isNegative = false; + if (number < 0) { + isNegative = true; + } + return isNegative; + } +} + +export default MyMath; + diff --git a/packages/mocha-runner/testResources/esm-project/src/my-math.spec.js b/packages/mocha-runner/testResources/esm-project/src/my-math.spec.js new file mode 100644 index 0000000000..49845435b7 --- /dev/null +++ b/packages/mocha-runner/testResources/esm-project/src/my-math.spec.js @@ -0,0 +1,54 @@ +import { expect } from 'chai'; +import MyMath from './my-math.js'; + +describe('MyMath', function () { + let myMath; + + beforeEach(function () { + myMath = new MyMath(); + }); + + it('should be able to add two numbers', function () { + var num1 = 2; + var num2 = 5; + var expected = num1 + num2; + + var actual = myMath.add(num1, num2); + + expect(actual).to.equal(expected); + }); + + it('should be able 1 to a number', function () { + var number = 2; + var expected = 3; + + var actual = myMath.addOne(number); + + expect(actual).to.equal(expected); + }); + + it('should be able negate a number', function () { + var number = 2; + var expected = -2; + + var actual = myMath.negate(number); + + expect(actual).to.equal(expected); + }); + + it('should be able to recognize a negative number', function () { + var number = -2; + + var isNegative = myMath.isNegativeNumber(number); + + expect(isNegative).to.equal(true); + }); + + it('should be able to recognize that 0 is not a negative number', function () { + var number = 0; + + var isNegative = myMath.isNegativeNumber(number); + + expect(isNegative).to.equal(false); + }); +}); diff --git a/packages/mocha-runner/tsconfig.src.json b/packages/mocha-runner/tsconfig.src.json index e761be83e6..9f20ad9e41 100644 --- a/packages/mocha-runner/tsconfig.src.json +++ b/packages/mocha-runner/tsconfig.src.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.settings.json", "compilerOptions": { "outDir": "dist", - "rootDir": "." + "rootDir": ".", }, "include": [ "schema/*.json",