Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new event package #45

Merged
merged 23 commits into from
Apr 3, 2019
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions docs/event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Events

A strict type-safe event system with multiple emitter patterns.

## Installation

```
yarn add @boost/event
```

## Usage

The event system is built around individual `Event` classes that can be instantiated in isolation,
register and unregister their own listeners, and emit values by executing each listener with
arguments. There are multiple [types of events](#types), so choose the best one for each use case.

To begin using events, instantiate an `Event` with a unique name -- the name is purely for debugging
purposes.

```ts
import { Event } from '@boost/event';

const event = new Event<[string]>('example');
```

`Event`s utilize TypeScript generics for typing the arguments passed to listener functions. This can
be defined using a tuple or an array.

```ts
// One argument of type number
new Event<[number]>('foo');

// Two arguments of type number and string
new Event<[number, string]>('bar');

// Three arguments with the last item being optional
new Event<[object, boolean, string?]>('baz');

// Array of any type or size
new Event<unknown[]>('foo');
```

### Registering Listeners

Listeners are simply functions that can be registered to an event using `Event#listen`. The same
listener function reference will only be registered once.

```ts
event.listen(listener);
```

A listener can also be registered to execute only once, using `Event#once`, regardless of how many
times the event has been emitted.

```ts
event.once(listener);
```

### Unregistering Listeners

A listener can be unregistered from an event using `Event#unlisten`. The same listener reference
used to register must also be used for unregistering.

```ts
event.unlisten(listener);
```

### Emitting Events

Emitting is the concept of executing all registered listeners with a set of arguments. This can be
achieved through the `Event#emit` method, which requires an array of values to pass to each listener
as arguments.

```ts
event.emit(['abc']);
```

> The array values and its types should match the [generics defined](#usage) on the constructor.
### Scopes

Scopes are a mechanism for restricting listeners to a unique subset. Scopes are defined as the 2nd
argument to `Event#listen`, `unlisten`, `once`, and `emit`.

```ts
event.listen(listener);
event.listen(listener, 'foo');
event.listen(listener, 'bar');

// Will only execute the 1st listener
event.emit([]);

// Will only execute the 2nd listener
event.emit([], 'foo');
```

## Types

There are 4 types of events that can be instantiated and emitted.

### `Event`

Standard event that executes listeners in the order they were registered.

```ts
import { Event } from '@boost/event';

const event = new Event<[string, number]>('standard');

event.listen(listener);

event.emit(['abc', 123]);
```

### `BailEvent`

Like `Event` but can bail the execution loop early if a listener returns `false`. The `emit` method
will return `true` if a bail occurs.

```ts
import { BailEvent } from '@boost/event';

const event = new BailEvent<[object]>('bail');

// Will execute
event.listen(() => {});

// Will execute and bail
event.listen(() => false);

// Will not execute
event.listen(() => {});

const bailed = event.emit([{ example: true }]);
```

### `ConcurrentEvent`

Executes listeners in parallel and returns a promise with the result of all listeners.

```ts
import { ConcurrentEvent } from '@boost/event';

const event = new ConcurrentEvent<[]>('parallel');

event.listen(doHeavyProcess);
event.listen(doBackgroundJob);

// Async/await
const result = await event.emit([]);

// Promise
event.emit([]).then(result => {});
```

### `WaterfallEvent`

Executes each listener in order, passing the previous listeners return value as an argument to the
next listener.

```ts
import { WaterfallEvent } from '@boost/event';

const event = new WaterfallEvent<number>('waterfall');

event.listen(num => num * 2);
event.listen(num => num * 3);

const result = event.emit(10); // 60
```

> This event only accepts a single argument. The generic type should not be an array, as it types
> the only argument and the return type.
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@
"release": "lerna publish"
},
"devDependencies": {
"@milesj/build-tools": "^0.34.0",
"@milesj/build-tools": "^0.37.2",
"fs-extra": "^7.0.1",
"lerna": "^3.13.1"
},
@@ -43,6 +43,11 @@
],
"settings": {
"node": true
},
"typescript": {
"exclude": [
"tests/typings.ts"
]
}
}
}
20 changes: 20 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# 1.11.0

#### 🚀 New

- Added a new package, [@boost/event](https://www.npmjs.com/package/@boost/event), to provide a
type-safe static event system. The old event emitter is deprecated, so please migrate to the new
system!
- Added `onError`, `onRoutine`, `onRoutines`, `onStart`, `onStop`, `onTask`, and `onTasks` events
to `Console`.
- Added `onRoutine`, `onRoutines`,`onTask`, and `onTasks` events to `Executor`.
- Added `onCommand` and `onCommandData` events to `Routine`.
- Added `onFail`, `onPass`, `onRun`, and `onSkip` to `Task` and `Routine`.
- Added `onExit` to `Tool`.
- Tasks and Routines can be skipped during their run process if an `onRun` event listener returns
`false`.

#### 🛠 Internal

- Started writing [documentation using GitBook](https://milesj.gitbook.io/boost/).

# 1.10.1 - 2019-03-24

#### 🐞 Fixes
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@
"access": "public"
},
"dependencies": {
"@boost/event": "^0.0.0",
"@types/cli-truncate": "^1.1.0",
"@types/debug": "^4.1.2",
"@types/execa": "^0.9.0",
28 changes: 28 additions & 0 deletions packages/core/src/Console.ts
Original file line number Diff line number Diff line change
@@ -3,9 +3,12 @@
import exit from 'exit';
import cliSize from 'term-size';
import ansiEscapes from 'ansi-escapes';
import { Event } from '@boost/event';
import Emitter from './Emitter';
import Task from './Task';
import Tool from './Tool';
import Output from './Output';
import Routine from './Routine';
import SignalError from './SignalError';
import { Debugger } from './types';

@@ -41,6 +44,20 @@ export default class Console extends Emitter {

logs: string[] = [];

onError: Event<[Error]>;

onRoutine: Event<[Routine<any, any>, unknown, boolean]>;

onRoutines: Event<[Routine<any, any>[], unknown, boolean]>;

onStart: Event<unknown[]>;

onStop: Event<[Error | null]>;

onTask: Event<[Task<any>, unknown, boolean]>;

onTasks: Event<[Task<any>[], unknown, boolean]>;

outputQueue: Output[] = [];

tool: Tool<any>;
@@ -63,6 +80,14 @@ export default class Console extends Emitter {
this.tool = tool;
this.writers = testWriters;

this.onError = new Event('error');
this.onRoutine = new Event('routine');
this.onRoutines = new Event('routines');
this.onStart = new Event('start');
this.onStop = new Event('stop');
this.onTask = new Event('task');
this.onTasks = new Event('tasks');

// istanbul ignore next
if (process.env.NODE_ENV !== 'test') {
process
@@ -324,6 +349,7 @@ export default class Console extends Emitter {
}

this.emit('error', [error]);
this.onError.emit([error]);
} else {
if (this.logs.length > 0) {
this.out(`\n${this.logs.join('\n')}\n`);
@@ -371,6 +397,7 @@ export default class Console extends Emitter {

this.debug('Starting console render loop');
this.emit('start', args);
this.onStart.emit(args);
this.wrapStreams();
this.displayHeader();
this.state.started = true;
@@ -411,6 +438,7 @@ export default class Console extends Emitter {
this.renderFinalOutput(error);
this.unwrapStreams();
this.emit('stop', [error]);
this.onStop.emit([error]);
this.state.stopped = true;
this.state.started = false;
}
34 changes: 18 additions & 16 deletions packages/core/src/Emitter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import camelCase from 'lodash/camelCase';
import upperFirst from 'lodash/upperFirst';
import { EVENT_NAME_PATTERN } from './constants';

export type EventArguments = any[];
@@ -25,22 +27,6 @@ export default class Emitter {
return this;
}

/**
* Syncronously execute listeners for the defined event and arguments,
* with the ability to intercept and abort early with a value.
*/
// emitCascade<T>(name: string, args: EventArguments = []): T | void {
// let value;

// Array.from(this.getListeners(this.createEventName(name))).some(listener => {
// value = listener(...args);

// return typeof value !== 'undefined';
// });

// return value;
// }

/**
* Return all event names with registered listeners.
*/
@@ -83,6 +69,22 @@ export default class Emitter {
throw new TypeError(`Invalid event listener for "${eventName}", must be a function.`);
}

let eventProp = eventName;
const args = ['listener'];

if (eventName.includes('.')) {
const [scope, event] = eventName.split('.', 2);

args.push(`'${scope}'`);
eventProp = event;
}

console.warn(
`Boost emitter has been deprecated. Please migrate \`on('${eventName}', listener)\` to the new @boost/event system, \`on${upperFirst(
camelCase(eventProp),
)}.listen(${args.join(', ')})\`.'`,
);

this.getListeners(eventName).add(listener);

return this;
46 changes: 8 additions & 38 deletions packages/core/src/Executor.ts
Original file line number Diff line number Diff line change
@@ -57,59 +57,28 @@ export default abstract class Executor<Ctx extends Context, Options = {}> {
* Execute a routine with the provided value.
*/
executeRoutine = async <T>(routine: Routine<Ctx, any>, value?: T): Promise<any> => {
const { console: cli } = this.tool;
let result = null;
this.tool.console.emit('routine', [routine, value, this.parallel]);
this.tool.console.onRoutine.emit([routine, value, this.parallel]);

cli.emit('routine', [routine, value, this.parallel]);

try {
result = await routine.run(this.context, value);

if (routine.isSkipped()) {
cli.emit('routine.skip', [routine, value, this.parallel]);
} else {
cli.emit('routine.pass', [routine, result, this.parallel]);
}
} catch (error) {
cli.emit('routine.fail', [routine, error, this.parallel]);

throw error;
}

return result;
return routine.run(this.context, value);
};

/**
* Execute a task with the provided value.
*/
executeTask = async <T>(task: Task<Ctx>, value?: T): Promise<any> => {
const { console: cli } = this.tool;
let result = null;

cli.emit('task', [task, value, this.parallel]);

try {
result = await task.run(this.context, value);

if (task.isSkipped()) {
cli.emit('task.skip', [task, value, this.parallel]);
} else {
cli.emit('task.pass', [task, result, this.parallel]);
}
} catch (error) {
cli.emit('task.fail', [task, error, this.parallel]);

throw error;
}
this.tool.console.emit('task', [task, value, this.parallel]);
this.tool.console.onTask.emit([task, value, this.parallel]);

return result;
return task.run(this.context, value);
};

/**
* Run all routines with the defined executor.
*/
runRoutines<T>(routines: Routine<Ctx, any>[], value?: T): Promise<any> {
this.tool.console.emit(this.parallel ? 'routines.parallel' : 'routines', [routines, value]);
this.tool.console.onRoutines.emit([routines, value, this.parallel]);

return this.run(this.executeRoutine as any, routines, value);
}
@@ -119,6 +88,7 @@ export default abstract class Executor<Ctx extends Context, Options = {}> {
*/
runTasks<T>(tasks: Task<Ctx>[], value?: T): Promise<any> {
this.tool.console.emit(this.parallel ? 'tasks.parallel' : 'tasks', [tasks, value]);
this.tool.console.onTasks.emit([tasks, value, this.parallel]);

return this.run(this.executeTask, tasks, value);
}
3 changes: 2 additions & 1 deletion packages/core/src/Reporter.ts
Original file line number Diff line number Diff line change
@@ -39,7 +39,8 @@ export default class Reporter<Options extends object = {}> extends Plugin<Option
* Register console listeners.
*/
bootstrap() {
this.console.on('start', this.handleBaseStart).on('stop', this.handleBaseStop);
this.console.onStart.listen(this.handleBaseStart);
this.console.onStop.listen(this.handleBaseStop);
}

/**
14 changes: 10 additions & 4 deletions packages/core/src/Routine.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import execa, { Options as ExecaOptions, ExecaChildProcess, ExecaReturns } from
import split from 'split';
import { Readable } from 'stream';
import optimal, { predicates, Blueprint, Predicates } from 'optimal';
import { Event } from '@boost/event';
import Context from './Context';
import Task, { TaskAction } from './Task';
import CoreTool from './Tool';
@@ -31,6 +32,10 @@ export default abstract class Routine<

key: string = '';

onCommand: Event<[string]>;

onCommandData: Event<[string, string]>;

options: Options;

parent: Routine<Ctx, Tool> | null = null;
@@ -55,6 +60,9 @@ export default abstract class Routine<
this.options = optimal(options, this.blueprint(predicates), {
name: this.constructor.name,
});

this.onCommand = new Event('command');
this.onCommandData = new Event('command.data');
}

/**
@@ -102,8 +110,7 @@ export default abstract class Routine<
? execa.shell(`${command} ${args.join(' ')}`.trim(), opts)
: execa(command, args, opts);

this.emit('command', [command]);
this.tool.console.emit('command', [command, this]);
this.onCommand.emit([command]);

// Push chunks to the reporter
const unit = task || this;
@@ -116,8 +123,7 @@ export default abstract class Routine<
unit.statusText = line;
}

this.emit('command.data', [command, line]);
this.tool.console.emit('command.data', [command, line, this]);
this.onCommandData.emit([command, line]);
}
};

26 changes: 22 additions & 4 deletions packages/core/src/Task.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Context from './Context';
import { Event, BailEvent } from '@boost/event';
import Emitter from './Emitter';
import Context from './Context';
import {
STATUS_PENDING,
STATUS_RUNNING,
@@ -13,7 +14,7 @@ export type TaskAction<Ctx extends Context> = (
context: Ctx,
value: any,
task: Task<Ctx>,
) => any | Promise<any>;
) => unknown | Promise<unknown>;

export interface TaskMetadata {
depth: number;
@@ -37,7 +38,15 @@ export default class Task<Ctx extends Context> extends Emitter {
stopTime: 0,
};

output: string = '';
onFail: Event<[Error | null]>;

onPass: Event<[unknown]>;

onRun: BailEvent<[unknown]>;

onSkip: Event<[unknown]>;

output: unknown = '';

parent: Task<Ctx> | null = null;

@@ -59,6 +68,10 @@ export default class Task<Ctx extends Context> extends Emitter {
this.action = action;
this.status = STATUS_PENDING;
this.title = title;
this.onFail = new Event('fail');
this.onPass = new Event('pass');
this.onRun = new BailEvent('run');
this.onSkip = new Event('skip');
}

/**
@@ -110,9 +123,12 @@ export default class Task<Ctx extends Context> extends Emitter {
this.setContext(context);
this.emit('run', [value]);

if (this.isSkipped()) {
const skip = this.onRun.emit([value]);

if (skip || this.isSkipped()) {
this.status = STATUS_SKIPPED;
this.emit('skip', [value]);
this.onSkip.emit([value]);

return Promise.resolve(value);
}
@@ -125,10 +141,12 @@ export default class Task<Ctx extends Context> extends Emitter {
this.status = STATUS_PASSED;
this.metadata.stopTime = Date.now();
this.emit('pass', [this.output]);
this.onPass.emit([this.output]);
} catch (error) {
this.status = STATUS_FAILED;
this.metadata.stopTime = Date.now();
this.emit('fail', [error]);
this.onFail.emit([error]);

throw error;
}
6 changes: 6 additions & 0 deletions packages/core/src/Tool.ts
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ import i18next from 'i18next';
import mergeWith from 'lodash/mergeWith';
import optimal, { bool, object, string, Blueprint } from 'optimal';
import parseArgs, { Arguments, Options as ArgOptions } from 'yargs-parser';
import { Event } from '@boost/event';
import ConfigLoader from './ConfigLoader';
import Console from './Console';
import Emitter from './Emitter';
@@ -85,6 +86,8 @@ export default class Tool<

debug: Debugger;

onExit: Event<[number]>;

options: Required<ToolOptions>;

package: PackageConfig = { name: '', version: '0.0.0' };
@@ -132,6 +135,8 @@ export default class Tool<
},
);

this.onExit = new Event('exit');

// Core debugger for the entire tool
this.debug = this.createDebugger('core');

@@ -160,6 +165,7 @@ export default class Tool<
// Cleanup when an exit occurs
process.on('exit', code => {
this.emit('exit', [code]);
this.onExit.emit([code]);
});
}
}
2 changes: 1 addition & 1 deletion packages/core/src/outputs/ProgressOutput.ts
Original file line number Diff line number Diff line change
@@ -70,7 +70,7 @@ export default class ProgressOutput extends Output<ProgressRenderer> {
}

// Compile our template
const progress = Math.min(Math.max(current / total, 0.0), 1.0);
const progress = Math.min(Math.max(current / total, 0), 1);
const percent = Math.floor(progress * 100);
const elapsed = Date.now() - this.startTime;
const estimated = percent === 100 ? 0 : elapsed * (total / current - 1);
8 changes: 4 additions & 4 deletions packages/core/src/reporters/BoostReporter.ts
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ export default class BoostReporter extends Reporter {
bootstrap() {
super.bootstrap();

this.console.on('routine', this.handleRoutine);
this.console.onRoutine.listen(this.handleRoutine);
}

handleRoutine = (routine: Routine<any, any>) => {
@@ -26,9 +26,9 @@ export default class BoostReporter extends Reporter {
output.enqueue(true);
};

routine.on('skip', handler);
routine.on('pass', handler);
routine.on('fail', handler);
routine.onFail.listen(handler);
routine.onPass.listen(handler);
routine.onSkip.listen(handler);
};

getRoutineLineParts(routine: Routine<any, any>): LineParts {
19 changes: 10 additions & 9 deletions packages/core/src/reporters/CIReporter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Reporter from '../Reporter';
import Routine from '../Routine';

export default class CIReporter extends Reporter {
routineCount: number = 0;
@@ -8,19 +9,19 @@ export default class CIReporter extends Reporter {
bootstrap() {
super.bootstrap();

this.console
.disable()
.on('stop', this.handleStop)
.on('task', this.handleTask)
.on('routine', this.handleRoutine)
.on('routine.skip', this.handleRoutineSkip)
.on('routine.pass', this.handleRoutinePass)
.on('routine.fail', this.handleRoutineFail);
this.console.disable();
this.console.onStop.listen(this.handleStop);
this.console.onRoutine.listen(this.handleRoutine);
this.console.onTask.listen(this.handleTask);
}

handleRoutine = () => {
handleRoutine = (routine: Routine<any, any>) => {
this.routineCount += 1;
this.console.out('.');

routine.onFail.listen(this.handleRoutineFail);
routine.onPass.listen(this.handleRoutinePass);
routine.onSkip.listen(this.handleRoutineSkip);
};

handleRoutineSkip = () => {
2 changes: 1 addition & 1 deletion packages/core/src/reporters/ErrorReporter.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ export default class ErrorReporter extends Reporter {
bootstrap() {
super.bootstrap();

this.console.on('error', this.handleError);
this.console.onError.listen(this.handleError);
}

handleError = (error: Error) => {
24 changes: 8 additions & 16 deletions packages/core/tests/Executor.test.ts
Original file line number Diff line number Diff line change
@@ -67,7 +67,7 @@ describe('Executor', () => {
expect(routine.status).toBe(STATUS_FAILED);
});

it('emits console events when skipped', async () => {
it('emits console event `routine` when skipped', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;
@@ -77,21 +77,19 @@ describe('Executor', () => {
await executor.executeRoutine(routine, 123);

expect(spy).toHaveBeenCalledWith('routine', [routine, 123, false]);
expect(spy).toHaveBeenCalledWith('routine.skip', [routine, 123, false]);
});

it('emits console events if a success', async () => {
it('emits console event `routine` if a success', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;

await executor.executeRoutine(routine, 123);

expect(spy).toHaveBeenCalledWith('routine', [routine, 123, false]);
expect(spy).toHaveBeenCalledWith('routine.pass', [routine, 123, false]);
});

it('emits console events if a failure', async () => {
it('emits console event `routine` if a failure', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;
@@ -108,10 +106,9 @@ describe('Executor', () => {
}

expect(spy).toHaveBeenCalledWith('routine', [routine, 123, false]);
expect(spy).toHaveBeenCalledWith('routine.fail', [routine, new Error('Oops'), false]);
});

it('emits console events with parallel flag', async () => {
it('emits console event `routine` with parallel flag', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;
@@ -120,7 +117,6 @@ describe('Executor', () => {
await executor.executeRoutine(routine, 123);

expect(spy).toHaveBeenCalledWith('routine', [routine, 123, true]);
expect(spy).toHaveBeenCalledWith('routine.pass', [routine, 123, true]);
});
});

@@ -159,7 +155,7 @@ describe('Executor', () => {
expect(task.status).toBe(STATUS_FAILED);
});

it('emits console events when skipped', async () => {
it('emits console event `task` when skipped', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;
@@ -169,21 +165,19 @@ describe('Executor', () => {
await executor.executeTask(task, 123);

expect(spy).toHaveBeenCalledWith('task', [task, 123, false]);
expect(spy).toHaveBeenCalledWith('task.skip', [task, 123, false]);
});

it('emits console events if a success', async () => {
it('emits console event `task` if a success', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;

await executor.executeTask(task, 123);

expect(spy).toHaveBeenCalledWith('task', [task, 123, false]);
expect(spy).toHaveBeenCalledWith('task.pass', [task, 369, false]);
});

it('emits console events if a failure', async () => {
it('emits console event `task` if a failure', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;
@@ -199,10 +193,9 @@ describe('Executor', () => {
}

expect(spy).toHaveBeenCalledWith('task', [task, 123, false]);
expect(spy).toHaveBeenCalledWith('task.fail', [task, new Error('Oops'), false]);
});

it('emits console events with parallel flag', async () => {
it('emits console event `task` with parallel flag', async () => {
const spy = jest.fn();

executor.tool.console.emit = spy;
@@ -211,7 +204,6 @@ describe('Executor', () => {
await executor.executeTask(task, 123);

expect(spy).toHaveBeenCalledWith('task', [task, 123, true]);
expect(spy).toHaveBeenCalledWith('task.pass', [task, 369, true]);
});
});

7 changes: 4 additions & 3 deletions packages/core/tests/Reporter.test.ts
Original file line number Diff line number Diff line change
@@ -24,12 +24,13 @@ describe('Reporter', () => {

describe('bootstrap()', () => {
it('sets start and stop events', () => {
const spy = jest.spyOn(reporter.console, 'on');
const startSpy = jest.spyOn(reporter.console.onStart, 'listen');
const stopSpy = jest.spyOn(reporter.console.onStop, 'listen');

reporter.bootstrap();

expect(spy).toHaveBeenCalledWith('start', expect.anything());
expect(spy).toHaveBeenCalledWith('stop', expect.anything());
expect(startSpy).toHaveBeenCalledWith(expect.anything());
expect(stopSpy).toHaveBeenCalledWith(expect.anything());
});
});

15 changes: 4 additions & 11 deletions packages/core/tests/Routine.test.ts
Original file line number Diff line number Diff line change
@@ -221,23 +221,16 @@ describe('Routine', () => {
});

it('pipes stdout/stderr to handler', async () => {
const spy1 = jest.spyOn(routine, 'emit');
const spy2 = jest.spyOn(routine.tool.console, 'emit');
const commandSpy = jest.spyOn(routine.onCommand, 'emit');
const commandDataSpy = jest.spyOn(routine.onCommandData, 'emit');
const task = new Task('title', () => {});

task.status = STATUS_RUNNING;

await routine.executeCommand('yarn', ['-v'], { task });

expect(spy1).toHaveBeenCalledWith('command', ['yarn']);
expect(spy1).toHaveBeenCalledWith('command.data', ['yarn', expect.anything()]);

expect(spy2).toHaveBeenCalledWith('command', ['yarn', expect.anything()]);
expect(spy2).toHaveBeenCalledWith('command.data', [
'yarn',
expect.anything(),
expect.anything(),
]);
expect(commandSpy).toHaveBeenCalledWith(['yarn']);
expect(commandDataSpy).toHaveBeenCalledWith(['yarn', expect.anything()]);
});

it('sets `statusText` on task', async () => {
10 changes: 5 additions & 5 deletions packages/core/tests/reporters/BoostReporter.test.ts
Original file line number Diff line number Diff line change
@@ -54,11 +54,11 @@ describe('BoostReporter', () => {

describe('bootstrap()', () => {
it('binds events', () => {
const spy = jest.spyOn(reporter.console, 'on');
const spy = jest.spyOn(reporter.console.onRoutine, 'listen');

reporter.bootstrap();

expect(spy).toHaveBeenCalledWith('routine', expect.anything());
expect(spy).toHaveBeenCalledWith(expect.anything());
});
});

@@ -87,23 +87,23 @@ describe('BoostReporter', () => {
it('marks as final when `skip` event is emitted', () => {
reporter.handleRoutine(parent);

parent.emit('skip');
parent.onSkip.emit(['']);

expect(reporter.console.outputQueue[0].isFinal()).toBe(true);
});

it('marks as final when `pass` event is emitted', () => {
reporter.handleRoutine(parent);

parent.emit('pass');
parent.onPass.emit(['']);

expect(reporter.console.outputQueue[0].isFinal()).toBe(true);
});

it('marks as final when `fail` event is emitted', () => {
reporter.handleRoutine(parent);

parent.emit('fail');
parent.onFail.emit([null]);

expect(reporter.console.outputQueue[0].isFinal()).toBe(true);
});
19 changes: 9 additions & 10 deletions packages/core/tests/reporters/CIReporter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import chalk from 'chalk';
import { mockTool, mockConsole } from '../../src/testUtils';
import { mockTool, mockConsole, mockRoutine } from '../../src/testUtils';
import CIReporter from '../../src/reporters/CIReporter';

describe('CIReporter', () => {
@@ -20,16 +20,15 @@ describe('CIReporter', () => {

describe('bootstrap()', () => {
it('binds events', () => {
const spy = jest.spyOn(reporter.console, 'on');
const startSpy = jest.spyOn(reporter.console.onStart, 'listen');
const routineSpy = jest.spyOn(reporter.console.onRoutine, 'listen');
const taskSpy = jest.spyOn(reporter.console.onTask, 'listen');

reporter.bootstrap();

expect(spy).toHaveBeenCalledWith('stop', expect.anything());
expect(spy).toHaveBeenCalledWith('task', expect.anything());
expect(spy).toHaveBeenCalledWith('routine', expect.anything());
expect(spy).toHaveBeenCalledWith('routine.skip', expect.anything());
expect(spy).toHaveBeenCalledWith('routine.pass', expect.anything());
expect(spy).toHaveBeenCalledWith('routine.fail', expect.anything());
expect(startSpy).toHaveBeenCalledWith(expect.anything());
expect(routineSpy).toHaveBeenCalledWith(expect.anything());
expect(taskSpy).toHaveBeenCalledWith(expect.anything());
});
});

@@ -53,13 +52,13 @@ describe('CIReporter', () => {
it('increments count', () => {
expect(reporter.routineCount).toBe(0);

reporter.handleRoutine();
reporter.handleRoutine(mockRoutine(reporter.tool));

expect(reporter.routineCount).toBe(1);
});

it('logs a period', () => {
reporter.handleRoutine();
reporter.handleRoutine(mockRoutine(reporter.tool));

expect(outSpy).toHaveBeenCalledWith('.');
});
4 changes: 2 additions & 2 deletions packages/core/tests/reporters/ErrorReporter.test.ts
Original file line number Diff line number Diff line change
@@ -14,11 +14,11 @@ describe('ErrorReporter', () => {

describe('bootstrap()', () => {
it('binds events', () => {
const spy = jest.spyOn(reporter.console, 'on');
const errorSpy = jest.spyOn(reporter.console.onError, 'listen');

reporter.bootstrap();

expect(spy).toHaveBeenCalledWith('error', expect.anything());
expect(errorSpy).toHaveBeenCalledWith(expect.anything());
});
});

6 changes: 6 additions & 0 deletions packages/event/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
src/
tests/
types/
*.lock
*.log
tsconfig.json
17 changes: 17 additions & 0 deletions packages/event/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 1.0.0

#### 🎉 Release

- Initial release!

#### 🚀 Updates

- Added `Event`, which synchronously fires listeners.
- Added `BailEvent`, which will bail the loop if a listener returns `false`.
- Added `ConcurrentEvent`, which asynchronously fires listeners and return a promise.
- Added `WaterfallEvent`, which passes the return value to each listener.

#### 🛠 Internals

- **[ts]** Refactored the type system to strictly and explicitly type all possible events,
listeners, and their arguments.
21 changes: 21 additions & 0 deletions packages/event/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2019 Miles Johnson

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
19 changes: 19 additions & 0 deletions packages/event/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Boost Event

[![Build Status](https://travis-ci.org/milesj/boost.svg?branch=master)](https://travis-ci.org/milesj/boost)
[![npm version](https://badge.fury.io/js/%40boost%event.svg)](https://www.npmjs.com/package/@boost/event)
[![npm deps](https://david-dm.org/milesj/boost.svg?path=packages/event)](https://www.npmjs.com/package/@boost/event)

A strict type-safe event system with multiple emitter patterns.

## Installation

```
yarn add @boost/event
// Or
npm install @boost/event
```

## Documentation

[https://milesj.gitbook.io/boost](https://milesj.gitbook.io/boost)
16 changes: 16 additions & 0 deletions packages/event/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@boost/event",
"version": "0.0.0",
"description": "A type-safe event system. Designed for Boost applications.",
"keywords": ["boost", "event", "emitter", "type-safe"],
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"engines": {
"node": ">=8.9.0"
},
"repository": "https://github.com/milesj/boost/tree/master/packages/event",
"license": "MIT",
"publishConfig": {
"access": "public"
}
}
13 changes: 13 additions & 0 deletions packages/event/src/BailEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import BaseEvent from './BaseEvent';
import { Scope } from './types';

export default class BailEvent<Args extends unknown[]> extends BaseEvent<Args, boolean | void> {
/**
* Synchronously execute listeners with the defined arguments.
* If a listener returns `false`, the loop with be aborted early,
* and the emitter will return `true` (for bailed).
*/
emit(args: Args, scope?: Scope): boolean {
return Array.from(this.getListeners(scope)).some(listener => listener(...args) === false);
}
}
110 changes: 110 additions & 0 deletions packages/event/src/BaseEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { EVENT_NAME_PATTERN, DEFAULT_SCOPE } from './constants';
import { Listener, Scope } from './types';

export default abstract class BaseEvent<Args extends unknown[], Return> {
listeners: Map<string, Set<Listener<Args, Return>>> = new Map();

name: string;

constructor(name: string) {
this.name = this.validateName(name, 'name');
}

/**
* Remove all listeners from the event.
*/
clearListeners(scope?: Scope): this {
if (scope) {
this.getListeners(scope).clear();
} else {
this.listeners.clear();
}

return this;
}

/**
* Return a set of listeners for a specific event scope.
*/
getListeners(scope: Scope = DEFAULT_SCOPE): Set<Listener<Args, Return>> {
const key = this.validateName(scope, 'scope');

if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}

return this.listeners.get(key)!;
}

/**
* Return a list of all scopes with listeners.
*/
getScopes(): string[] {
return Array.from(this.listeners.keys());
}

/**
* Register a listener to the event.
*/
listen(listener: Listener<Args, Return>, scope?: Scope): this {
this.getListeners(scope).add(this.validateListener(listener));

return this;
}

/**
* Register a listener to the event that only triggers once.
*/
once(listener: Listener<Args, Return>, scope?: Scope): this {
const func = this.validateListener(listener);
const handler: any = (...args: unknown[]) => {
this.unlisten(handler);

return func(...args);
};

return this.listen(handler, scope);
}

/**
* Remove a listener from the event.
*/
unlisten(listener: Listener<Args, Return>, scope?: Scope): this {
this.getListeners(scope).delete(listener);

return this;
}

/**
* Validate the listener is a function.
*/
protected validateListener<L>(listener: L): L {
if (typeof listener !== 'function') {
throw new TypeError(`Invalid event listener for "${this.name}", must be a function.`);
}

return listener;
}

/**
* Validate the name/scope match a defined pattern.
*/
protected validateName(name: string, type: string): string {
if (type === 'scope' && name === DEFAULT_SCOPE) {
return name;
}

if (!name.match(EVENT_NAME_PATTERN)) {
throw new Error(
`Invalid event ${type} "${name}". May only contain dashes, periods, and lowercase characters.`,
);
}

return name;
}

/**
* Emit the event by executing all scoped listeners with the defined arguments.
*/
abstract emit(args: unknown, scope?: Scope): unknown;
}
15 changes: 15 additions & 0 deletions packages/event/src/ConcurrentEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import BaseEvent from './BaseEvent';
import { Scope } from './types';

export default class ConcurrentEvent<Args extends unknown[]> extends BaseEvent<
Args,
Promise<unknown>
> {
/**
* Asynchronously execute listeners for with the defined arguments.
* Will return a promise with an array of each listener result.
*/
emit(args: Args, scope?: Scope): Promise<unknown[]> {
return Promise.all(Array.from(this.getListeners(scope)).map(listener => listener(...args)));
}
}
13 changes: 13 additions & 0 deletions packages/event/src/Event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import BaseEvent from './BaseEvent';
import { Scope } from './types';

export default class Event<Args extends unknown[]> extends BaseEvent<Args, void> {
/**
* Synchronously execute listeners with the defined arguments.
*/
emit(args: Args, scope?: Scope) {
Array.from(this.getListeners(scope)).forEach(listener => {
listener(...args);
});
}
}
15 changes: 15 additions & 0 deletions packages/event/src/WaterfallEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import BaseEvent from './BaseEvent';
import { Scope } from './types';

export default class WaterfallEvent<Arg> extends BaseEvent<[Arg], Arg> {
/**
* Synchronously execute listeners with the defined argument value.
* The return value of each listener will be passed as an argument to the next listener.
*/
emit(arg: Arg, scope?: Scope): Arg {
return Array.from(this.getListeners(scope)).reduce(
(nextValue, listener) => listener(nextValue),
arg,
);
}
}
3 changes: 3 additions & 0 deletions packages/event/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const DEFAULT_SCOPE = '*';

export const EVENT_NAME_PATTERN = /^[a-z]{1}[-.a-z0-9]*[a-z]{1}$/u;
17 changes: 17 additions & 0 deletions packages/event/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @copyright 2017-2019, Miles Johnson
* @license https://opensource.org/licenses/MIT
*/

import BailEvent from './BailEvent';
import BaseEvent from './BaseEvent';
import Event from './Event';
import ConcurrentEvent from './ConcurrentEvent';
import WaterfallEvent from './WaterfallEvent';

export * from './constants';

// eslint-disable-next-line import/export
export * from './types';

export { BailEvent, BaseEvent, Event, ConcurrentEvent, WaterfallEvent };
11 changes: 11 additions & 0 deletions packages/event/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type Listener<A extends unknown[], R> = A extends [infer A1, infer A2, infer A3]
? (a1: A1, a2: A2, a3: A3) => R
: A extends [infer A1, infer A2]
? (a1: A1, a2: A2) => R
: A extends [infer A1]
? (a1: A1) => R
: A extends unknown[]
? (...args: A) => R
: never;

export type Scope = string;
91 changes: 91 additions & 0 deletions packages/event/tests/BailEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import BailEvent from '../src/BailEvent';

describe('BailEvent', () => {
let event: BailEvent<[string, number]>;

beforeEach(() => {
event = new BailEvent('bail.test');
});

it('executes listeners in order', () => {
let output = '';

event.listen(value => {
output += value;
output += 'B';
});
event.listen(() => {
output += 'C';
});
event.listen(() => {
output += 'D';
});

const result = event.emit(['A', 0]);

expect(result).toBe(false);
expect(output).toBe('ABCD');
});

it('executes listeners based on scope', () => {
let output = '';

event.listen(() => {
output += 'A';
}, 'foo');
event.listen(() => {
output += 'B';
}, 'bar');
event.listen(() => {
output += 'C';
}, 'baz');

const result = event.emit(['A', 0], 'bar');

expect(result).toBe(false);
expect(output).toBe('B');
});

it('bails the loop if a listener returns false', () => {
let count = 0;

event.listen(() => {
count += 1;
});
event.listen(() => {
count += 1;

return false;
});
event.listen(() => {
count += 1;
});

const result = event.emit(['', 0]);

expect(result).toBe(true);
expect(count).toBe(2);
});

it('doesnt bail the loop if a listener returns true', () => {
let count = 0;

event.listen((string, number) => {
count += number;
count += 1;
});
event.listen(() => {
count += 1;

return true;
});
event.listen(() => {
count += 1;
});

const result = event.emit(['', 1]);

expect(result).toBe(false);
expect(count).toBe(4);
});
});
183 changes: 183 additions & 0 deletions packages/event/tests/BaseEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import Event from '../src/Event';

describe('Event', () => {
let event: Event<any[]>;

beforeEach(() => {
event = new Event('event.test');
});

describe('constructor()', () => {
it('errors if name contains invalid characters', () => {
expect(() => new Event('foo+bar')).toThrowErrorMatchingSnapshot();
});
});

describe('clearListeners()', () => {
it('deletes all listeners in a scope', () => {
event.listen(() => {}, 'foo');
event.listen(() => {}, 'bar');
event.listen(() => {}, 'baz');

expect(event.getListeners('foo').size).toBe(1);
expect(event.getListeners('bar').size).toBe(1);
expect(event.getListeners('baz').size).toBe(1);

event.clearListeners('foo');

expect(event.getListeners('foo').size).toBe(0);
expect(event.getListeners('bar').size).toBe(1);
expect(event.getListeners('baz').size).toBe(1);
});

it('deletes all listeners across all scopes', () => {
event.listen(() => {}, 'foo');
event.listen(() => {}, 'bar');
event.listen(() => {}, 'baz');

expect(event.getListeners('foo').size).toBe(1);
expect(event.getListeners('bar').size).toBe(1);
expect(event.getListeners('baz').size).toBe(1);

event.clearListeners();

expect(event.getListeners('foo').size).toBe(0);
expect(event.getListeners('bar').size).toBe(0);
expect(event.getListeners('baz').size).toBe(0);
});
});

describe('getListeners()', () => {
it('errors if scope contains invalid characters', () => {
expect(() => event.getListeners('foo+bar')).toThrowErrorMatchingSnapshot();
});

it('doesnt error for default scope', () => {
expect(() => event.getListeners()).not.toThrowError();
});

it('creates the listeners set if it does not exist', () => {
expect(event.listeners.has('foo')).toBe(false);

const set = event.getListeners('foo');

expect(set).toBeInstanceOf(Set);
expect(event.listeners.has('foo')).toBe(true);
});
});

describe('getScopes()', () => {
it('returns an array of scope names', () => {
event.getListeners();
event.getListeners('foo');
event.getListeners('bar');
event.getListeners('baz');

expect(event.getScopes()).toEqual(['*', 'foo', 'bar', 'baz']);
});
});

describe('listen()', () => {
it('errors if listener is not a function', () => {
expect(() => {
// @ts-ignore Check invalid type
event.listen(123);
}).toThrowErrorMatchingSnapshot();
});

it('adds a listener to the default scope', () => {
const listener = () => {};

expect(event.getListeners().has(listener)).toBe(false);

event.listen(listener);

expect(event.getListeners().has(listener)).toBe(true);
});

it('adds a listener to a specific scope', () => {
const listener = () => {};

expect(event.getListeners('foo').has(listener)).toBe(false);

event.listen(listener, 'foo');

expect(event.getListeners('foo').has(listener)).toBe(true);
});
});

describe('unlisten()', () => {
it('removes a listener from the default scope', () => {
const listener = () => {};

event.listen(listener);

expect(event.getListeners().has(listener)).toBe(true);

event.unlisten(listener);

expect(event.getListeners().has(listener)).toBe(false);
});

it('removes a listener from a specific scope', () => {
const listener = () => {};

event.listen(listener, 'foo');

expect(event.getListeners('foo').has(listener)).toBe(true);

event.unlisten(listener, 'foo');

expect(event.getListeners('foo').has(listener)).toBe(false);
});
});

describe('once()', () => {
it('errors if listener is not a function', () => {
expect(() => {
// @ts-ignore Check invalid type
event.once(123);
}).toThrowErrorMatchingSnapshot();
});

it('adds a listener to the default scope', () => {
const listener = () => {};

expect(event.getListeners().size).toBe(0);

event.once(listener);

expect(event.getListeners().has(listener)).toBe(false);
expect(event.getListeners().size).toBe(1);
});

it('adds a listener to a specific scope', () => {
const listener = () => {};

expect(event.getListeners('foo').size).toBe(0);

event.once(listener, 'foo');

expect(event.getListeners('foo').has(listener)).toBe(false);
expect(event.getListeners('foo').size).toBe(1);
});

it('removes the listener once executed', () => {
let count = 0;
const listener = () => {
count += 1;
};

event.once(listener);

expect(event.getListeners().size).toBe(1);

event.emit([]);
event.emit([]);
event.emit([]);

expect(event.getListeners().size).toBe(0);
expect(count).toBe(1);
});
});
});
58 changes: 58 additions & 0 deletions packages/event/tests/ConcurrentEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import ConcurrentEvent from '../src/ConcurrentEvent';

describe('ConcurrentEvent', () => {
let event: ConcurrentEvent<[number]>;

beforeEach(() => {
event = new ConcurrentEvent('parallel.test');
});

beforeEach(() => {
jest.useRealTimers();
});

afterEach(() => {
jest.useFakeTimers();
});

it('returns a promise', () => {
expect(event.emit([0])).toBeInstanceOf(Promise);
});

it('executes listeners asynchronously with arguments', async () => {
const output: number[] = [];

function getRandom() {
return Math.round(Math.random() * (500 - 0) + 0);
}

event.listen(
value =>
new Promise<number>(resolve => {
setTimeout(() => {
resolve(value * 2);
}, getRandom());
}),
);
event.listen(
value =>
new Promise<number>(resolve => {
setTimeout(() => {
resolve(value * 3);
}, getRandom());
}),
);
event.listen(
value =>
new Promise<number>(resolve => {
setTimeout(() => {
resolve(value * 4);
}, getRandom());
}),
);

await event.emit([1]);

expect(output).not.toEqual([2, 3, 4]);
});
});
48 changes: 48 additions & 0 deletions packages/event/tests/Event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Event from '../src/Event';

describe('Event', () => {
let event: Event<[string, string, string]>;

beforeEach(() => {
event = new Event('event.test');
});

it('executes listeners synchronously while passing values to each', () => {
let value = 'foo';

event.listen(() => {
value = value.toUpperCase();
});
event.listen(() => {
value = value
.split('')
.reverse()
.join('');
});
event.listen(() => {
value = `${value}-${value}`;
});

event.emit(['', '', '']);

expect(value).toBe('OOF-OOF');
});

it('executes listeners synchronously with arguments', () => {
const value: string[] = [];

event.listen(a => {
value.push(a.repeat(3));
});
event.listen((a, b) => {
value.push(b.repeat(2));
});
event.listen((a, b, c) => {
value.push(c.repeat(1));
});

event.emit(['foo', 'bar', 'baz']);

expect(value).toEqual(['foofoofoo', 'barbar', 'baz']);
});
});
44 changes: 44 additions & 0 deletions packages/event/tests/WaterfallEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import WaterfallEvent from '../src/WaterfallEvent';

describe('WaterfallEvent', () => {
it('executes listeners in order with the value being passed to each function', () => {
const event = new WaterfallEvent<string>('waterfall.test');

event.listen(value => `${value}B`);
event.listen(value => `${value}C`);
event.listen(value => `${value}D`);

const output = event.emit('A');

expect(output).toBe('ABCD');
});

it('supports arrays', () => {
const event = new WaterfallEvent<string[]>('waterfall.array.test');

event.listen(value => [...value, 'B']);
event.listen(value => [...value, 'C']);
event.listen(value => [...value, 'D']);

const output = event.emit(['A']);

expect(output).toEqual(['A', 'B', 'C', 'D']);
});

it('supports objects', () => {
const event = new WaterfallEvent<{ [key: string]: string }>('waterfall.array.test');

event.listen(value => ({ ...value, B: 'B' }));
event.listen(value => ({ ...value, C: 'C' }));
event.listen(value => ({ ...value, D: 'D' }));

const output = event.emit({ A: 'A' });

expect(output).toEqual({
A: 'A',
B: 'B',
C: 'C',
D: 'D',
});
});
});
9 changes: 9 additions & 0 deletions packages/event/tests/__snapshots__/BaseEvent.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Event constructor() errors if name contains invalid characters 1`] = `"Invalid event name \\"foo+bar\\". May only contain dashes, periods, and lowercase characters."`;

exports[`Event getListeners() errors if scope contains invalid characters 1`] = `"Invalid event scope \\"foo+bar\\". May only contain dashes, periods, and lowercase characters."`;

exports[`Event listen() errors if listener is not a function 1`] = `"Invalid event listener for \\"event.test\\", must be a function."`;

exports[`Event once() errors if listener is not a function 1`] = `"Invalid event listener for \\"event.test\\", must be a function."`;
55 changes: 55 additions & 0 deletions packages/event/tests/typings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/* eslint-disable */

import { Event, BailEvent, ConcurrentEvent, WaterfallEvent } from '../src';

const foo = new Event<[number, string?]>('foo');
const bar = new BailEvent<[number, number, object]>('bar');
const baz = new ConcurrentEvent<unknown[]>('baz');
const qux = new WaterfallEvent<string>('qux');

// VALID
foo.listen(() => {});
foo.listen((num, str) => {});
foo.emit([123]);
foo.emit([123, 'abc']);

// INVALID
foo.listen((num, str, bool) => {});
foo.emit([true]);
foo.emit([123, 456]);
foo.emit([123, 'abc', true]);

// VALID
bar.listen(() => {});
bar.listen((num, str) => true);
bar.listen((num, str, obj) => false);
bar.emit([123, 456, {}]);

// INVALID
bar.listen(() => 123);
bar.emit([true]);
bar.emit([123, 'abc']);
bar.emit([123, 456, 'abc']);
const notBoolReturn: string = bar.emit([123, 456, {}]);

// VALID
baz.listen(() => Promise.resolve());
baz.listen((num, str) => Promise.reject());
baz.emit([]);
baz.emit([123, 456, {}]);

// INVALID
baz.listen(() => 123);
const notPromiseReturn: string = baz.emit(['abc']);

// VALID
qux.listen(() => 'abc');
qux.listen(str => str.toUpperCase());
qux.emit('qux');

// INVALID
qux.listen(() => 123);
qux.listen(() => {});
qux.emit(123);
qux.emit(['qux']);
const notStringReturn: number = qux.emit('abc');
21 changes: 12 additions & 9 deletions packages/reporter-nyan/src/NyanReporter.ts
Original file line number Diff line number Diff line change
@@ -30,13 +30,10 @@ export default class NyanReporter extends Reporter {
this.rainbowWidth = this.size().columns - this.catWidth * 2;
this.rainbowColors = this.generateColors();

this.console
.on('start', this.handleStart)
.on('stop', this.handleStop)
.on('routine', this.handleRoutine)
.on('routine.pass', this.handleRoutine)
.on('routine.fail', this.handleRoutine)
.on('task', this.handleTask);
this.console.onStart.listen(this.handleStart);
this.console.onStop.listen(this.handleStop);
this.console.onRoutine.listen(this.handleRoutine);
this.console.onTask.listen(this.handleTask);
}

handleStart = () => {
@@ -50,7 +47,13 @@ export default class NyanReporter extends Reporter {
};

handleRoutine = (routine: Routine<any, any>) => {
this.activeRoutine = routine;
const handler = () => {
this.activeRoutine = routine;
};

routine.onFail.listen(handler);
routine.onPass.listen(handler);
handler();
};

handleTask = (task: Task<any>) => {
@@ -73,7 +76,7 @@ export default class NyanReporter extends Reporter {

for (let i = 0; i < 6 * 7; i += 1) {
const pi3 = Math.floor(Math.PI / 3);
const n = i * (1.0 / 6);
const n = i * (1 / 6);
const r = Math.floor(3 * Math.sin(n) + 3);
const g = Math.floor(3 * Math.sin(n + 2 * pi3) + 3);
const b = Math.floor(3 * Math.sin(n + 4 * pi3) + 3);
15 changes: 8 additions & 7 deletions packages/reporter-nyan/tests/NyanReporter.test.ts
Original file line number Diff line number Diff line change
@@ -16,16 +16,17 @@ describe('NyanReporter', () => {

describe('bootstrap()', () => {
it('binds events', () => {
const spy = jest.spyOn(reporter.console, 'on');
const startSpy = jest.spyOn(reporter.console.onStart, 'listen');
const stopSpy = jest.spyOn(reporter.console.onStop, 'listen');
const routineSpy = jest.spyOn(reporter.console.onRoutine, 'listen');
const taskSpy = jest.spyOn(reporter.console.onTask, 'listen');

reporter.bootstrap();

expect(spy).toHaveBeenCalledWith('start', expect.anything());
expect(spy).toHaveBeenCalledWith('stop', expect.anything());
expect(spy).toHaveBeenCalledWith('routine', expect.anything());
expect(spy).toHaveBeenCalledWith('routine.pass', expect.anything());
expect(spy).toHaveBeenCalledWith('routine.fail', expect.anything());
expect(spy).toHaveBeenCalledWith('task', expect.anything());
expect(startSpy).toHaveBeenCalledWith(expect.anything());
expect(stopSpy).toHaveBeenCalledWith(expect.anything());
expect(routineSpy).toHaveBeenCalledWith(expect.anything());
expect(taskSpy).toHaveBeenCalledWith(expect.anything());
});

it('generates rainbow data', () => {
13 changes: 3 additions & 10 deletions scripts/build-packages.sh
Original file line number Diff line number Diff line change
@@ -12,14 +12,7 @@ build_pkg() {
node ../../node_modules/.bin/tsc
}

build_pkg "./packages/event"
build_pkg "./packages/core"
cd "$root" || exit

REGEX="/(core|theme)"

for pkg in ./packages/*; do
if ! [[ "$pkg" =~ $REGEX ]]
then
build_pkg "$pkg"
fi
done
build_pkg "./packages/reporter-nyan"
build_pkg "./packages/test-utils"
649 changes: 420 additions & 229 deletions yarn.lock

Large diffs are not rendered by default.