Skip to content

Commit

Permalink
feat(plugin): add support for declareValuePlugin (#4490)
Browse files Browse the repository at this point in the history
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
  }
});
```
  • Loading branch information
nicojs authored Oct 14, 2023
1 parent a9e28ac commit a3c35ca
Show file tree
Hide file tree
Showing 17 changed files with 215 additions and 44 deletions.
12 changes: 8 additions & 4 deletions docs/disable-mutants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
40 changes: 19 additions & 21 deletions e2e/test/ignore-project/stryker-plugins/ignorers/console-ignorer.js
Original file line number Diff line number Diff line change
@@ -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;
},
}),
];
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="@stryker-mutator/api/ignore" />
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 {}
}
1 change: 0 additions & 1 deletion package-lock.json

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

3 changes: 1 addition & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -63,7 +63,6 @@
"typed-inject": "~4.0.0"
},
"devDependencies": {
"@babel/core": "7.23.2",
"@types/node": "18.18.5"
}
}
2 changes: 1 addition & 1 deletion packages/api/schema/stryker-core.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
File renamed without changes.
File renamed without changes.
32 changes: 30 additions & 2 deletions packages/api/src/plugin/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -14,7 +14,8 @@ import { PluginKind } from './plugin-kind.js';
*/
export type Plugin<TPluginKind extends PluginKind> =
| ClassPlugin<TPluginKind, Array<InjectionToken<PluginContext>>>
| FactoryPlugin<TPluginKind, Array<InjectionToken<PluginContext>>>;
| FactoryPlugin<TPluginKind, Array<InjectionToken<PluginContext>>>
| ValuePlugin<TPluginKind>;

/**
* Represents a plugin that is created with a factory method
Expand All @@ -28,6 +29,15 @@ export interface FactoryPlugin<TPluginKind extends PluginKind, Tokens extends Ar
readonly factory: InjectableFunction<PluginContext, PluginInterfaces[TPluginKind], Tokens>;
}

/**
* Represents a plugin that is provided as a simple value.
*/
export interface ValuePlugin<TPluginKind extends PluginKind> {
readonly kind: TPluginKind;
readonly name: string;
readonly value: PluginInterfaces[TPluginKind];
}

/**
* Represents a plugin that is created by instantiating a class.
*/
Expand Down Expand Up @@ -77,6 +87,24 @@ export function declareFactoryPlugin<TPluginKind extends PluginKind, Tokens exte
};
}

/**
* Declare a value plugin. Use this method for simple plugins where you don't need values to be injected.
* @param kind The plugin kind
* @param name The name of the plugin
* @param value The plugin
*/
export function declareValuePlugin<TPluginKind extends PluginKind>(
kind: TPluginKind,
name: string,
value: PluginInterfaces[TPluginKind],
): ValuePlugin<TPluginKind> {
return {
value,
kind,
name,
};
}

/**
* Lookup type for plugin interfaces by kind.
*/
Expand Down
16 changes: 15 additions & 1 deletion packages/api/test/unit/plugin/plugins.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
});
});
});
});
13 changes: 9 additions & 4 deletions packages/core/src/di/plugin-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
InjectionToken,
tokens,
commonTokens,
ValuePlugin,
} from '@stryker-mutator/api/plugin';
import { InjectableFunction, InjectableClass } from 'typed-inject';

Expand All @@ -32,9 +33,10 @@ export class PluginCreator {
return this.injector.injectClass(
plugin.injectableClass as InjectableClass<PluginContext, PluginInterfaces[TPlugin], Array<InjectionToken<PluginContext>>>,
);
} 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<T extends keyof Plugins>(kind: T, name: string): Plugins[T] {
Expand All @@ -55,8 +57,11 @@ export class PluginCreator {
}

function isFactoryPlugin(plugin: Plugin<PluginKind>): plugin is FactoryPlugin<PluginKind, Array<InjectionToken<PluginContext>>> {
return !!(plugin as FactoryPlugin<PluginKind, Array<InjectionToken<PluginContext>>>).factory;
return Boolean((plugin as FactoryPlugin<PluginKind, Array<InjectionToken<PluginContext>>>).factory);
}
function isClassPlugin(plugin: Plugin<PluginKind>): plugin is ClassPlugin<PluginKind, Array<InjectionToken<PluginContext>>> {
return !!(plugin as ClassPlugin<PluginKind, Array<InjectionToken<PluginContext>>>).injectableClass;
return Boolean((plugin as ClassPlugin<PluginKind, Array<InjectionToken<PluginContext>>>).injectableClass);
}
function isValuePlugin(plugin: Plugin<PluginKind>): plugin is ValuePlugin<PluginKind> {
return Boolean((plugin as ValuePlugin<PluginKind>).value);
}
38 changes: 38 additions & 0 deletions packages/core/test/integration/di/plugins.it.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
21 changes: 19 additions & 2 deletions packages/core/test/unit/di/plugin-creator.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<PluginKind.Reporter> = {
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');
Expand Down Expand Up @@ -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',
);
});

Expand Down
68 changes: 68 additions & 0 deletions packages/core/testResources/plugins/custom-plugins.js
Original file line number Diff line number Diff line change
@@ -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<import('@stryker-mutator/api/core').schema.MutationTestResult>} 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';
}
},
}),
];
4 changes: 2 additions & 2 deletions packages/instrumenter/src/transformers/ignorer-bookkeeper.ts
Original file line number Diff line number Diff line change
@@ -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 {}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Ignorer } from '@stryker-mutator/api/ignorer';
import { Ignorer } from '@stryker-mutator/api/ignore';

import { MutatorOptions } from '../mutators/index.js';

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down

0 comments on commit a3c35ca

Please sign in to comment.