From a3c35caa3b2dba7036e1ebf081c74fa594f88d03 Mon Sep 17 00:00:00 2001 From: Nico Jansen Date: Sun, 15 Oct 2023 01:00:11 +0200 Subject: [PATCH] feat(plugin): add support for `declareValuePlugin` (#4490) Add support for `declareValuePlugin`. With it, you can define a plugin as a value instead of using a factory method or class. ```js export const strykerPlugins = [declareValuePlugin(PluginKind.Ignorer, { shouldIgnore(path) { // tada } }); ``` --- docs/disable-mutants.md | 12 ++-- .../ignorers/console-ignorer.js | 40 ++++++----- .../stryker-plugins/ignorers/global.types.ts | 5 +- package-lock.json | 1 - packages/api/package.json | 3 +- packages/api/schema/stryker-core.json | 2 +- .../api/src/{ignorer => ignore}/ignorer.ts | 0 packages/api/src/{ignorer => ignore}/index.ts | 0 packages/api/src/plugin/plugins.ts | 32 ++++++++- packages/api/test/unit/plugin/plugins.spec.ts | 16 ++++- packages/core/src/di/plugin-creator.ts | 13 ++-- .../test/integration/di/plugins.it.spec.ts | 38 +++++++++++ .../core/test/unit/di/plugin-creator.spec.ts | 21 +++++- .../testResources/plugins/custom-plugins.js | 68 +++++++++++++++++++ .../src/transformers/ignorer-bookkeeper.ts | 4 +- .../src/transformers/transformer-options.ts | 2 +- .../transformers/ignorer-bookkeeper.spec.ts | 2 +- 17 files changed, 215 insertions(+), 44 deletions(-) rename packages/api/src/{ignorer => ignore}/ignorer.ts (100%) rename packages/api/src/{ignorer => ignore}/index.ts (100%) create mode 100644 packages/core/test/integration/di/plugins.it.spec.ts create mode 100644 packages/core/testResources/plugins/custom-plugins.js diff --git a/docs/disable-mutants.md b/docs/disable-mutants.md index a68e737233..4195208053 100644 --- a/docs/disable-mutants.md +++ b/docs/disable-mutants.md @@ -45,10 +45,14 @@ Mutant 1 and 2 are killed by the tests. However, mutant 3 isn't killed. In fact, ## Disable mutants -StrykerJS supports 2 ways to disable mutants. - -1. [Exclude the mutator](#exclude-the-mutator). -2. [Using a `// Stryker disable` comment](#using-a--stryker-disable-comment). +StrykerJS supports 3 ways to disable mutants. + +1. [Exclude the mutator](#exclude-the-mutator).\ + Great if you are not interested in a specific mutator. +2. [Using a `// Stryker disable` comment](#using-a--stryker-disable-comment).\ + Good for one-off ignoring of mutants. +3. [Using an `Ignorer` plugin](#using-an-ignorer-plugin).\ + Good Disabled mutants will still end up in your report, but will get the `ignored` status. This means that they don't influence your mutation score, but are still visible if you want to look for them. This has no impact on the performance of mutation testing. diff --git a/e2e/test/ignore-project/stryker-plugins/ignorers/console-ignorer.js b/e2e/test/ignore-project/stryker-plugins/ignorers/console-ignorer.js index 6c02e29f19..01c6886075 100644 --- a/e2e/test/ignore-project/stryker-plugins/ignorers/console-ignorer.js +++ b/e2e/test/ignore-project/stryker-plugins/ignorers/console-ignorer.js @@ -1,23 +1,21 @@ // @ts-check -import { PluginKind, declareClassPlugin } from '@stryker-mutator/api/plugin'; +import { PluginKind, declareValuePlugin } from '@stryker-mutator/api/plugin'; -export class ConsoleIgnorer { - /** - * @param {import('@stryker-mutator/api/ignorer').NodePath} path - */ - shouldIgnore(path) { - if ( - path.isExpressionStatement() && - path.node.expression.type === 'CallExpression' && - path.node.expression.callee.type === 'MemberExpression' && - path.node.expression.callee.object.type === 'Identifier' && - path.node.expression.callee.object.name === 'console' && - path.node.expression.callee.property.type === 'Identifier' && - path.node.expression.callee.property.name === 'log' - ) { - return "We're not interested in console.log statements for now"; - } - return undefined; - } -} -export const strykerPlugins = [declareClassPlugin(PluginKind.Ignorer, 'ConsoleIgnorer', ConsoleIgnorer)]; +export const strykerPlugins = [ + declareValuePlugin(PluginKind.Ignorer, 'ConsoleIgnorer', { + shouldIgnore(path) { + if ( + path.isExpressionStatement() && + path.node.expression.type === 'CallExpression' && + path.node.expression.callee.type === 'MemberExpression' && + path.node.expression.callee.object.type === 'Identifier' && + path.node.expression.callee.object.name === 'console' && + path.node.expression.callee.property.type === 'Identifier' && + path.node.expression.callee.property.name === 'log' + ) { + return "We're not interested in console.log statements for now"; + } + return undefined; + }, + }), +]; diff --git a/e2e/test/ignore-project/stryker-plugins/ignorers/global.types.ts b/e2e/test/ignore-project/stryker-plugins/ignorers/global.types.ts index 2dde1145f0..843d72df8e 100644 --- a/e2e/test/ignore-project/stryker-plugins/ignorers/global.types.ts +++ b/e2e/test/ignore-project/stryker-plugins/ignorers/global.types.ts @@ -1,6 +1,7 @@ +/// import type babel from '@babel/core'; -declare module '@stryker-mutator/api/ignorer' { +declare module '@stryker-mutator/api/ignore' { // eslint-disable-next-line @typescript-eslint/no-empty-interface - interface NodePath extends babel.NodePath {} + export interface NodePath extends babel.NodePath {} } diff --git a/package-lock.json b/package-lock.json index 8776316a2e..bfad8bdd01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24988,7 +24988,6 @@ "typed-inject": "~4.0.0" }, "devDependencies": { - "@babel/core": "7.23.2", "@types/node": "18.18.5" }, "engines": { diff --git a/packages/api/package.json b/packages/api/package.json index fe6f2ef348..bf87ae350f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -19,7 +19,7 @@ "exports": { "./check": "./dist/src/check/index.js", "./core": "./dist/src/core/index.js", - "./ignorer": "./dist/src/ignorer/index.js", + "./ignore": "./dist/src/ignore/index.js", "./logging": "./dist/src/logging/index.js", "./plugin": "./dist/src/plugin/index.js", "./report": "./dist/src/report/index.js", @@ -63,7 +63,6 @@ "typed-inject": "~4.0.0" }, "devDependencies": { - "@babel/core": "7.23.2", "@types/node": "18.18.5" } } diff --git a/packages/api/schema/stryker-core.json b/packages/api/schema/stryker-core.json index 7d821525c1..919a555e60 100644 --- a/packages/api/schema/stryker-core.json +++ b/packages/api/schema/stryker-core.json @@ -533,7 +533,7 @@ "default": false }, "ignorers": { - "description": "Enable ignorer plugins here. An ignorer plugin will be invoked on each AST node visitation and can decide to ignore the node or not. This can be useful for example to ignore all mutations in a console.log() statement.", + "description": "Enable ignorer plugins here. An ignorer plugin will be invoked on each AST node visitation and can decide to ignore the node or not. This can be useful for example to ignore all mutants in a console.log() statement.", "type": "array", "items": { "type": "string" diff --git a/packages/api/src/ignorer/ignorer.ts b/packages/api/src/ignore/ignorer.ts similarity index 100% rename from packages/api/src/ignorer/ignorer.ts rename to packages/api/src/ignore/ignorer.ts diff --git a/packages/api/src/ignorer/index.ts b/packages/api/src/ignore/index.ts similarity index 100% rename from packages/api/src/ignorer/index.ts rename to packages/api/src/ignore/index.ts diff --git a/packages/api/src/plugin/plugins.ts b/packages/api/src/plugin/plugins.ts index 0ffeea397c..a0905f6e09 100644 --- a/packages/api/src/plugin/plugins.ts +++ b/packages/api/src/plugin/plugins.ts @@ -4,7 +4,7 @@ import { Reporter } from '../report/index.js'; import { TestRunner } from '../test-runner/index.js'; import { Checker } from '../check/index.js'; -import { Ignorer } from '../ignorer/ignorer.js'; +import { Ignorer } from '../ignore/ignorer.js'; import { PluginContext } from './contexts.js'; import { PluginKind } from './plugin-kind.js'; @@ -14,7 +14,8 @@ import { PluginKind } from './plugin-kind.js'; */ export type Plugin = | ClassPlugin>> - | FactoryPlugin>>; + | FactoryPlugin>> + | ValuePlugin; /** * Represents a plugin that is created with a factory method @@ -28,6 +29,15 @@ export interface FactoryPlugin; } +/** + * Represents a plugin that is provided as a simple value. + */ +export interface ValuePlugin { + readonly kind: TPluginKind; + readonly name: string; + readonly value: PluginInterfaces[TPluginKind]; +} + /** * Represents a plugin that is created by instantiating a class. */ @@ -77,6 +87,24 @@ export function declareFactoryPlugin( + kind: TPluginKind, + name: string, + value: PluginInterfaces[TPluginKind], +): ValuePlugin { + return { + value, + kind, + name, + }; +} + /** * Lookup type for plugin interfaces by kind. */ diff --git a/packages/api/test/unit/plugin/plugins.spec.ts b/packages/api/test/unit/plugin/plugins.spec.ts index 14adf313f1..11f8dca732 100644 --- a/packages/api/test/unit/plugin/plugins.spec.ts +++ b/packages/api/test/unit/plugin/plugins.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { tokens, commonTokens, PluginKind, declareClassPlugin, declareFactoryPlugin } from '../../../src/plugin/index.js'; +import { tokens, commonTokens, PluginKind, declareClassPlugin, declareFactoryPlugin, declareValuePlugin } from '../../../src/plugin/index.js'; import { Logger } from '../../../src/logging/index.js'; import { MutantResult } from '../../../src/core/index.js'; @@ -41,4 +41,18 @@ describe('plugins', () => { }); }); }); + describe(declareValuePlugin.name, () => { + it('should declare a value plugin', () => { + const value = { + onMutantTested(_: MutantResult) { + // idle + }, + }; + expect(declareValuePlugin(PluginKind.Reporter, 'rep', value)).deep.eq({ + kind: PluginKind.Reporter, + name: 'rep', + value, + }); + }); + }); }); diff --git a/packages/core/src/di/plugin-creator.ts b/packages/core/src/di/plugin-creator.ts index 76033c98a0..1374f0bafb 100644 --- a/packages/core/src/di/plugin-creator.ts +++ b/packages/core/src/di/plugin-creator.ts @@ -10,6 +10,7 @@ import { InjectionToken, tokens, commonTokens, + ValuePlugin, } from '@stryker-mutator/api/plugin'; import { InjectableFunction, InjectableClass } from 'typed-inject'; @@ -32,9 +33,10 @@ export class PluginCreator { return this.injector.injectClass( plugin.injectableClass as InjectableClass>>, ); - } else { - throw new Error(`Plugin "${kind}:${name}" could not be created, missing "factory" or "injectableClass" property.`); + } else if (isValuePlugin(plugin)) { + return plugin.value; } + throw new Error(`Plugin "${kind}:${name}" could not be created, missing "factory", "injectableClass" or "value" property.`); } private findPlugin(kind: T, name: string): Plugins[T] { @@ -55,8 +57,11 @@ export class PluginCreator { } function isFactoryPlugin(plugin: Plugin): plugin is FactoryPlugin>> { - return !!(plugin as FactoryPlugin>>).factory; + return Boolean((plugin as FactoryPlugin>>).factory); } function isClassPlugin(plugin: Plugin): plugin is ClassPlugin>> { - return !!(plugin as ClassPlugin>>).injectableClass; + return Boolean((plugin as ClassPlugin>>).injectableClass); +} +function isValuePlugin(plugin: Plugin): plugin is ValuePlugin { + return Boolean((plugin as ValuePlugin).value); } diff --git a/packages/core/test/integration/di/plugins.it.spec.ts b/packages/core/test/integration/di/plugins.it.spec.ts new file mode 100644 index 0000000000..cd5524923e --- /dev/null +++ b/packages/core/test/integration/di/plugins.it.spec.ts @@ -0,0 +1,38 @@ +import { testInjector } from '@stryker-mutator/test-helpers'; +import { PluginKind } from '@stryker-mutator/api/plugin'; + +import { expect } from 'chai'; + +import { PluginLoader } from '../../../src/di/plugin-loader.js'; +import { PluginCreator } from '../../../src/di/plugin-creator.js'; +import { coreTokens } from '../../../src/di/index.js'; + +describe('Plugins integration', () => { + describe('local plugins', () => { + let pluginCreator: PluginCreator; + + beforeEach(async () => { + const loader = testInjector.injector.injectClass(PluginLoader); + const plugins = await loader.load(['./testResources/plugins/custom-plugins.js']); + pluginCreator = testInjector.injector.provideValue(coreTokens.pluginsByKind, plugins.pluginsByKind).injectClass(PluginCreator); + }); + + it('should be able to load a "ValuePlugin"', async () => { + const plugin = pluginCreator.create(PluginKind.Ignorer, 'console.debug'); + expect(plugin).ok; + expect(plugin.shouldIgnore).a('function'); + }); + + it('should be able to load a "FactoryPlugin"', async () => { + const plugin = pluginCreator.create(PluginKind.TestRunner, 'lazy'); + expect(plugin).ok; + expect(plugin.capabilities).a('function'); + }); + + it('should be able to load a "ClassPlugin"', async () => { + const plugin = pluginCreator.create(PluginKind.Reporter, 'console'); + expect(plugin).ok; + expect(plugin.onMutationTestReportReady).a('function'); + }); + }); +}); diff --git a/packages/core/test/unit/di/plugin-creator.spec.ts b/packages/core/test/unit/di/plugin-creator.spec.ts index 2f6fbb338c..db58ba89cb 100644 --- a/packages/core/test/unit/di/plugin-creator.spec.ts +++ b/packages/core/test/unit/di/plugin-creator.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { ClassPlugin, FactoryPlugin, Plugin, PluginKind } from '@stryker-mutator/api/plugin'; +import { ClassPlugin, FactoryPlugin, Plugin, PluginKind, ValuePlugin } from '@stryker-mutator/api/plugin'; import { factory, testInjector } from '@stryker-mutator/test-helpers'; import { coreTokens, PluginCreator } from '../../../src/di/index.js'; @@ -49,6 +49,23 @@ describe(PluginCreator.name, () => { expect(actualReporter).instanceOf(FooReporter); }); + it("should return a ValuePlugin using it's value", () => { + // Arrange + const expectedReporter = factory.reporter('foo'); + const valuePlugin: ValuePlugin = { + kind: PluginKind.Reporter, + name: 'foo', + value: expectedReporter, + }; + pluginsByKind.set(PluginKind.Reporter, [valuePlugin]); + + // Act + const actualReporter = sut.create(PluginKind.Reporter, 'foo'); + + // Assert + expect(actualReporter).eq(expectedReporter); + }); + it('should match plugins on name ignore case', () => { // Arrange const expectedReporter = factory.reporter('bar'); @@ -87,7 +104,7 @@ describe(PluginCreator.name, () => { }; pluginsByKind.set(PluginKind.Reporter, [errorPlugin]); expect(() => sut.create(PluginKind.Reporter, 'foo')).throws( - 'Plugin "Reporter:foo" could not be created, missing "factory" or "injectableClass" property.', + 'Plugin "Reporter:foo" could not be created, missing "factory", "injectableClass" or "value" property', ); }); diff --git a/packages/core/testResources/plugins/custom-plugins.js b/packages/core/testResources/plugins/custom-plugins.js new file mode 100644 index 0000000000..f7ab17b7f7 --- /dev/null +++ b/packages/core/testResources/plugins/custom-plugins.js @@ -0,0 +1,68 @@ +// @ts-check +import { PluginKind, commonTokens, declareClassPlugin, declareFactoryPlugin, declareValuePlugin } from '@stryker-mutator/api/plugin'; +import { DryRunStatus, MutantRunStatus } from '@stryker-mutator/api/test-runner'; + +/** + * @typedef {import('@stryker-mutator/api/test-runner').TestRunner} TestRunner + * @typedef {import('@stryker-mutator/api/plugin').Injector} Injector + */ + +class MyReporter { + static inject = [commonTokens.logger] /** @type {const} */; + + /** @param {import('@stryker-mutator/api/logging').Logger} logger */ + constructor(logger) { + this.logger = logger; + } + + /** @param {Readonly} result */ + onMutationTestReportReady(result) { + this.logger.info(`${result.files}`); + } +} + +/** + * @param {Injector} _injector + * @returns {TestRunner} + */1 +function createLazyTestRunner(_injector) { + return { + capabilities() { + return { reloadEnvironment: false }; + }, + + async dryRun() { + return { + status: DryRunStatus.Complete, + tests: [], + } + }, + async mutantRun() { + return { + status: MutantRunStatus.Error, + errorMessage: 'Not implemented', + } + } + }; +} +createLazyTestRunner.inject = [commonTokens.injector]; + +export const strykerPlugins = [ + declareClassPlugin(PluginKind.Reporter, 'console', MyReporter), + declareFactoryPlugin(PluginKind.TestRunner, 'lazy', createLazyTestRunner), + declareValuePlugin(PluginKind.Ignorer, 'console.debug', { + shouldIgnore(path) { + if ( + path.isExpresssionStatement() && + path.node.expression.type === 'CallExpression' && + path.node.expression.callee.type === 'MemberExpression' && + path.node.expression.callee.object.type === 'Identifier' && + path.node.expression.callee.object.name === 'console' && + path.node.expression.callee.property.type === 'Identifier' && + path.node.expression.callee.property.name === 'debug' + ) { + return 'ignoring console.debug'; + } + }, + }), +]; diff --git a/packages/instrumenter/src/transformers/ignorer-bookkeeper.ts b/packages/instrumenter/src/transformers/ignorer-bookkeeper.ts index 840f4e091c..d5c9039f8f 100644 --- a/packages/instrumenter/src/transformers/ignorer-bookkeeper.ts +++ b/packages/instrumenter/src/transformers/ignorer-bookkeeper.ts @@ -1,8 +1,8 @@ import type { types, NodePath as BabelNodePath } from '@babel/core'; -import type { Ignorer } from '@stryker-mutator/api/ignorer'; +import type { Ignorer } from '@stryker-mutator/api/ignore'; -declare module '@stryker-mutator/api/ignorer' { +declare module '@stryker-mutator/api/ignore' { // eslint-disable-next-line @typescript-eslint/no-empty-interface interface NodePath extends BabelNodePath {} } diff --git a/packages/instrumenter/src/transformers/transformer-options.ts b/packages/instrumenter/src/transformers/transformer-options.ts index beededfb4d..2cd9984ccc 100644 --- a/packages/instrumenter/src/transformers/transformer-options.ts +++ b/packages/instrumenter/src/transformers/transformer-options.ts @@ -1,4 +1,4 @@ -import { Ignorer } from '@stryker-mutator/api/ignorer'; +import { Ignorer } from '@stryker-mutator/api/ignore'; import { MutatorOptions } from '../mutators/index.js'; diff --git a/packages/instrumenter/test/unit/transformers/ignorer-bookkeeper.spec.ts b/packages/instrumenter/test/unit/transformers/ignorer-bookkeeper.spec.ts index c85dd632ec..f5f72fc9ab 100644 --- a/packages/instrumenter/test/unit/transformers/ignorer-bookkeeper.spec.ts +++ b/packages/instrumenter/test/unit/transformers/ignorer-bookkeeper.spec.ts @@ -1,4 +1,4 @@ -import { Ignorer } from '@stryker-mutator/api/ignorer'; +import { Ignorer } from '@stryker-mutator/api/ignore'; import { NodePath, types } from '@babel/core'; import { expect } from 'chai';