@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
210 lines (188 loc) • 6.57 kB
text/typescript
import { TestRunner } from '@stryker-mutator/api/test-runner';
import { notEmpty } from '@stryker-mutator/util';
import {
BehaviorSubject,
filter,
ignoreElements,
lastValueFrom,
mergeMap,
Observable,
ReplaySubject,
Subject,
takeUntil,
tap,
zip,
} from 'rxjs';
import { Disposable, tokens } from 'typed-inject';
import { CheckerFacade } from '../checker/index.js';
import { coreTokens } from '../di/index.js';
const MAX_CONCURRENT_INIT = 2;
/**
* Represents a TestRunner that is also a Resource (with an init and dispose)
*/
export type TestRunnerResource = Resource & TestRunner;
export interface Resource extends Partial<Disposable> {
init?(): Promise<void>;
}
createTestRunnerPool.inject = tokens(
coreTokens.testRunnerFactory,
coreTokens.testRunnerConcurrencyTokens,
);
export function createTestRunnerPool(
factory: () => TestRunnerResource,
concurrencyToken$: Observable<number>,
): Pool<TestRunner> {
return new Pool(factory, concurrencyToken$);
}
createCheckerPool.inject = tokens(
coreTokens.checkerFactory,
coreTokens.checkerConcurrencyTokens,
);
export function createCheckerPool(
factory: () => CheckerFacade,
concurrencyToken$: Observable<number>,
): Pool<CheckerFacade> {
return new Pool<CheckerFacade>(factory, concurrencyToken$);
}
/**
* Represents a work item: an input with a task and with a `result$` observable where the result (exactly one) will be streamed to.
*/
class WorkItem<TResource extends Resource, TIn, TOut> {
private readonly resultSubject = new Subject<TOut>();
public readonly result$ = this.resultSubject.asObservable();
/**
* @param input The input to the ask
* @param task The task, where a resource and input is presented
*/
constructor(
private readonly input: TIn,
private readonly task: (
resource: TResource,
input: TIn,
) => Promise<TOut> | TOut,
) {}
public async execute(resource: TResource) {
try {
const output = await this.task(resource, this.input);
this.resultSubject.next(output);
this.resultSubject.complete();
} catch (err) {
this.resultSubject.error(err);
}
}
public reject(error: unknown) {
this.resultSubject.error(error);
}
public complete() {
this.resultSubject.complete();
}
}
/**
* Represents a pool of resources. Use `schedule` to schedule work to be executed on the resources.
* The pool will automatically recycle the resources, but will make sure only one task is executed
* on one resource at any one time. Creates as many resources as the concurrency tokens allow.
* Also takes care of the initialing of the resources (with `init()`)
*/
export class Pool<TResource extends Resource> implements Disposable {
// The init subject. Using an RxJS subject instead of a promise, so errors are silently ignored when nobody is listening
private readonly initSubject = new ReplaySubject<void>();
// The disposedSubject emits true when it is disposed, and false when not disposed yet
private readonly disposedSubject = new BehaviorSubject(false);
// The dispose$ only emits one `true` value when disposed (never emits `false`). Useful for `takeUntil`
private readonly dispose$ = this.disposedSubject.pipe(
filter((isDisposed) => isDisposed),
);
private readonly createdResources: TResource[] = [];
// The queued work items. This is a replay subject, so scheduled work items can easily be rejected after it was picked up
private readonly todoSubject = new ReplaySubject<
WorkItem<TResource, any, any>
>();
constructor(factory: () => TResource, concurrencyToken$: Observable<number>) {
// Stream resources that are ready to pick up work
const resourcesSubject = new Subject<TResource>();
// Stream ongoing work.
zip(resourcesSubject, this.todoSubject)
.pipe(
mergeMap(async ([resource, workItem]) => {
await workItem.execute(resource);
resourcesSubject.next(resource); // recycle resource so it can pick up more work
}),
ignoreElements(),
takeUntil(this.dispose$),
)
.subscribe({
error: (error) => {
this.todoSubject.subscribe((workItem) => workItem.reject(error));
},
});
// Create resources
concurrencyToken$
.pipe(
takeUntil(this.dispose$),
mergeMap(async () => {
if (this.disposedSubject.value) {
// Don't create new resources when disposed
return;
}
const resource = factory();
this.createdResources.push(resource);
await resource.init?.();
return resource;
}, MAX_CONCURRENT_INIT),
filter(notEmpty),
tap({
complete: () => {
// Signal init complete
this.initSubject.next();
this.initSubject.complete();
},
error: (err) => {
this.initSubject.error(err);
},
}),
)
.subscribe({
next: (resource) => resourcesSubject.next(resource),
error: (err) => resourcesSubject.error(err),
});
}
/**
* Returns a promise that resolves if all concurrency tokens have resulted in initialized resources.
* This is optional, resources will get initialized either way.
*/
public async init(): Promise<void> {
await lastValueFrom(this.initSubject);
}
/**
* Schedules a task to be executed on resources in the pool. Each input is paired with a resource, which allows async work to be done.
* @param input$ The inputs to pair up with a resource.
* @param task The task to execute on each resource
*/
public schedule<TIn, TOut>(
input$: Observable<TIn>,
task: (resource: TResource, input: TIn) => Promise<TOut> | TOut,
): Observable<TOut> {
return input$.pipe(
mergeMap((input) => {
const workItem = new WorkItem(input, task);
this.todoSubject.next(workItem);
return workItem.result$;
}),
);
}
/**
* Dispose the pool
*/
public async dispose(): Promise<void> {
if (!this.disposedSubject.value) {
this.disposedSubject.next(true);
this.todoSubject.subscribe((workItem) => workItem.complete());
this.todoSubject.complete();
await Promise.all(
// We're mixing promises with undefined values, which triggers the lint warning. We can safely ignore it here.
// eslint-disable-next-line @typescript-eslint/await-thenable
this.createdResources.map((resource) => resource.dispose?.()),
);
}
}
}