const Emittery = require('emittery'); const Task = require('../../../lib/task'); const TaskRunner = require('../../../lib/task-runner'); const ARGUMENT_ERROR = 'ArgumentError'; const DEPENDENCY_CYCLE_ERROR = 'DependencyCycleError'; const STATE_ERROR = 'StateError'; const UNKNOWN_DEPENDENCY_ERROR = 'UnknownDependencyError'; const UNKNOWN_TASK_ERROR = 'UnknownTaskError'; function rethrower(e) { return () => { throw e; }; } describe('@jsdoc/task-runner/lib/task-runner', () => { let badTask; let bar; let barResult; const fakeTask = { name: 'foo', func: () => Promise.resolve(), }; let foo; let fooResult; let runner; beforeEach(() => { runner = new TaskRunner({}); foo = new Task({ name: 'foo', func: () => new Promise((resolve) => { fooResult = true; resolve(); }), }); fooResult = null; bar = new Task({ name: 'bar', func: () => new Promise((resolve) => { barResult = true; resolve(); }), }); barResult = null; badTask = new Task({ name: 'badTask', func: () => Promise.reject(new Error()), }); }); it('is a function', () => { expect(TaskRunner).toBeFunction(); }); it('inherits from emittery', () => { expect(runner instanceof Emittery).toBeTrue(); }); it('has no required parameters', () => { function factory() { return new TaskRunner(); } expect(factory).not.toThrow(); }); it('does not accept a non-object context', () => { function factory() { return new TaskRunner(7); } expect(factory).toThrowErrorOfType(ARGUMENT_ERROR); }); describe('addTask', () => { it('adds `Task` objects', () => { function add() { runner.addTask(foo); } expect(add).not.toThrow(); }); it('fails with non-`Task` objects', () => { function add() { runner.addTask(fakeTask); } expect(add).toThrowErrorOfType(ARGUMENT_ERROR); }); it('fails if the task runner is already running', () => { function addWhileRunning() { runner.run(); runner.addTask(foo); } expect(addWhileRunning).toThrowErrorOfType(STATE_ERROR); }); // We run the task, rather than just checking the value of `runner.tasks`, to make sure // _all_ the internal state was set correctly. it('correctly adds the task', async () => { runner.addTask(foo); await runner.run(); expect(fooResult).toBeTrue(); }); it('causes the task to be emitted when the task starts', async () => { let promise; runner.addTask(foo); promise = runner.once('taskStart'); foo.run(); await promise.then((event) => { expect(event).toBe(foo); }); }); it('causes the task to be emitted when the task ends', async () => { let event; let promise; runner.addTask(foo); promise = runner.once('taskEnd'); foo.run(); event = await promise; expect(event).toBe(foo); }); it('causes an error to be emitted if the task errors', async () => { let error; let event; let promise; runner.addTask(badTask); promise = runner.once('taskError'); try { await runner.run(); } catch (e) { error = e; } event = await promise; expect(event).toBeObject(); expect(rethrower(event.error)).toThrowError(); expect(event.error).toBe(error); expect(event.task).toBe(badTask); }); }); describe('addTasks', () => { it('accepts an object whose values are tasks', () => { function addTasks() { runner.addTasks({ foo }); } expect(addTasks).not.toThrow(); }); it('fails with an object whose values are not tasks', () => { function addTasks() { runner.addTasks({ foo: fakeTask }); } expect(addTasks).toThrowErrorOfType(ARGUMENT_ERROR); }); it('accepts an array of tasks', () => { function addTasks() { runner.addTasks([foo]); } expect(addTasks).not.toThrow(); }); it('fails with an array of non-tasks', () => { function addTasks() { runner.addTasks([fakeTask]); } expect(addTasks).toThrowErrorOfType(ARGUMENT_ERROR); }); it('fails with non-object, non-array input', () => { function addTasks() { runner.addTasks(7); } expect(addTasks).toThrowErrorOfType(ARGUMENT_ERROR); }); it('adds all the tasks in an object', () => { let tasks; runner.addTasks({ foo, bar, }); tasks = runner.tasks; expect(tasks.foo).toBe(foo); expect(tasks.bar).toBe(bar); }); it('adds all the tasks in an array', () => { let tasks; runner.addTasks([foo, bar]); tasks = runner.tasks; expect(tasks.foo).toBe(foo); expect(tasks.bar).toBe(bar); }); it('returns `this`', () => { const result = runner.addTasks({ foo, bar, }); expect(result).toBe(runner); }); }); describe('end', () => { it('stops the task runner', () => { function addAfterEnding() { runner.addTask(foo); runner.run(); runner.end(); runner.addTask(bar); } expect(addAfterEnding).not.toThrow(); }); it('resets the tasks', async () => { runner.addTask(foo); runner.run(); await runner.end(); expect(runner.tasks).toBeEmptyObject(); }); }); describe('removeTask', () => { it('removes `Task` objects', () => { runner.addTask(foo); runner.removeTask(foo); expect(runner.tasks.foo).toBeUndefined(); }); it('removes tasks by name', () => { runner.addTask(foo); runner.removeTask('foo'); expect(runner.tasks.foo).toBeUndefined(); }); it('fails on invalid input types', () => { function addRemove() { runner.addTask(foo); runner.removeTask(7); } expect(addRemove).toThrowErrorOfType(ARGUMENT_ERROR); }); it('fails on unknown tasks', () => { function addRemove() { runner.addTask(foo); runner.removeTask(bar); } expect(addRemove).toThrowErrorOfType(UNKNOWN_TASK_ERROR); }); it('fails if the task runner is already running', () => { function removeWhileRunning() { runner.addTasks([foo, bar]); runner.run(); runner.removeTask(foo); } expect(removeWhileRunning).toThrowErrorOfType(STATE_ERROR); }); it('correctly removes the task', async () => { let error; runner.addTasks([foo, bar]); runner.removeTask(foo); try { await runner.run(); } catch (e) { error = e; } expect(error).toBeUndefined(); expect(fooResult).toBeNull(); expect(barResult).toBeTrue(); }); it('prevents `taskStart`/`taskEnd` events for the task', async () => { let startEvent; let endEvent; runner.on('taskStart', (e) => { startEvent = e; }); runner.on('taskEnd', (e) => { endEvent = e; }); runner.addTask(foo); runner.removeTask(foo); await foo.run(); expect(startEvent).toBeUndefined(); expect(endEvent).toBeUndefined(); }); it('prevents `taskError` events for the task', async () => { let errorEvent; let taskErrorEvent; runner.addTask(badTask); runner.removeTask(badTask); badTask.on('error', (e) => { errorEvent = e; }); runner.on('taskError', (e) => { taskErrorEvent = e; }); try { await badTask.run(); } catch (e) { // Expected behavior. } expect(errorEvent).toBeObject(); expect(errorEvent.task).toBe(badTask); expect(taskErrorEvent).toBeUndefined(); }); it('returns `this`', () => { let result; runner.addTask(foo); result = runner.removeTask(foo); expect(result).toBe(runner); }); }); describe('removeTasks', () => { it('accepts an object whose values are tasks', () => { function removeTasks() { runner.removeTasks({ foo }); } runner.addTask(foo); expect(removeTasks).not.toThrow(); }); it('fails with an object whose values are not tasks', () => { function removeTasks() { runner.removeTasks({ foo: 7 }); } runner.addTask(foo); expect(removeTasks).toThrowErrorOfType(ARGUMENT_ERROR); }); it('accepts an array of tasks', () => { function removeTasks() { runner.removeTasks([foo]); } runner.addTask(foo); expect(removeTasks).not.toThrow(); }); it('accepts an array of strings', () => { function removeTasks() { runner.removeTasks(['foo']); } runner.addTask(foo); expect(removeTasks).not.toThrow(); }); it('fails with an array whose values are not tasks or strings', () => { function removeTasks() { runner.removeTasks([fakeTask]); } expect(removeTasks).toThrowErrorOfType(ARGUMENT_ERROR); }); it('fails with non-object, non-array input', () => { function removeTasks() { runner.removeTasks(7); } expect(removeTasks).toThrowErrorOfType(ARGUMENT_ERROR); }); it('fails with unknown tasks', () => { function removeTasks() { runner.removeTasks([bar]); } expect(removeTasks).toThrowErrorOfType(UNKNOWN_TASK_ERROR); }); it('removes all the tasks in an object', () => { const tasks = { foo, bar, }; runner.addTasks(tasks); runner.removeTasks(tasks); expect(runner.tasks).toBeEmptyObject(); }); it('removes all the tasks in an array', () => { const tasks = [foo, bar]; runner.addTasks(tasks); runner.removeTasks(tasks); expect(runner.tasks).toBeEmptyObject(); }); it('returns `this`', () => { runner.addTask(foo); expect(runner.removeTask(foo)).toBe(runner); }); }); describe('run', () => { let a; let b; let c; let taskA; let taskB; let taskC; beforeEach(() => { a = null; b = null; c = null; taskA = new Task({ name: 'a', func: () => { a = 5; return Promise.resolve(); }, }); taskB = new Task({ name: 'b', func: () => { b = a * 3; return Promise.resolve(); }, dependsOn: ['a'], }); taskC = new Task({ name: 'c', func: () => { c = a + b; return Promise.resolve(); }, dependsOn: ['b'], }); }); it('runs every task', async () => { runner.addTasks([taskA, taskB, taskC]); await runner.run(); expect(a).toBe(5); expect(b).toBe(15); expect(c).toBe(20); }); it('runs tasks with dependencies in the correct order', async () => { const results = []; taskA.on('end', () => { results.push(a); }); taskB.on('end', () => { results.push(b); }); taskC.on('end', () => { results.push(c); }); runner.addTasks([taskA, taskB, taskC]); await runner.run(); expect(results).toEqual([5, 15, 20]); }); it('fails if the task runner is already running', async () => { let error; runner.addTask(taskA); runner.run(); try { await runner.run(); } catch (e) { error = e; } expect(rethrower(error)).toThrowErrorOfType(STATE_ERROR); }); it('clears all of its state after it runs', async () => { runner.addTask(taskA); await runner.run(); expect(runner.tasks).toBeEmptyObject(); }); it('fails if a task errors', async () => { let error; runner.addTask(badTask); try { await runner.run(); } catch (e) { error = e; } expect(rethrower(error)).toThrowError(); }); describe('context', () => { it('prefers the context passed to `run()`', async () => { const context = {}; const t = new TaskRunner({}); t.addTask( new Task({ name: 'foo', func: (ctx) => { ctx.foo = 'foo'; return Promise.resolve(); }, }) ); await t.run(context); expect(context.foo).toBe('foo'); }); it('falls back on the context passed to the constructor', async () => { const context = {}; const t = new TaskRunner(context); t.addTask( new Task({ name: 'foo', func: (ctx) => { ctx.foo = 'foo'; return Promise.resolve(); }, }) ); await t.run(); expect(context.foo).toBe('foo'); }); it('fails if the context is not an object', async () => { let error; try { await new TaskRunner().run(7); } catch (e) { error = e; } expect(error).toBeErrorOfType(ARGUMENT_ERROR); }); it('passes the context to tasks with no dependencies', async () => { const context = {}; const r = new TaskRunner(context); r.addTask( new Task({ name: 'usesContext', func: (ctx) => { ctx.foo = 'bar'; return Promise.resolve(); }, }) ); await r.run(); expect(context.foo).toBe('bar'); }); it('passes the context to tasks with dependencies', async () => { const context = {}; const r = new TaskRunner(context); r.addTasks([ new Task({ name: 'one', func: (ctx) => { ctx.foo = 'bar'; return Promise.resolve(); }, }), new Task({ name: 'two', func: (ctx) => { ctx.bar = ctx.foo + ' baz'; return Promise.resolve(); }, dependsOn: ['one'], }), ]); await r.run(); expect(context.bar).toBe('bar baz'); }); }); describe('dependencies', () => { it('errors if a task depends on an unknown task', async () => { let error; runner.addTask( new Task({ name: 'badDependsOn', func: () => Promise.resolve(), dependsOn: ['mysteryTask'], }) ); try { await runner.run(); } catch (e) { error = e; } expect(rethrower(error)).toThrowErrorOfType(UNKNOWN_DEPENDENCY_ERROR); }); it('errors if there are circular dependencies', async () => { let error; runner.addTasks([ new Task({ name: 'one', func: () => Promise.resolve(), dependsOn: ['two'], }), new Task({ name: 'two', func: () => Promise.resolve(), dependsOn: ['one'], }), ]); try { await runner.run(); } catch (e) { error = e; } expect(rethrower(error)).toThrowErrorOfType(DEPENDENCY_CYCLE_ERROR); }); }); describe('events', () => { it('emits a `start` event', async () => { let emitted; runner.addTask(foo); runner.on('start', () => { emitted = true; }); await runner.run(); expect(emitted).toBeTrue(); }); it('emits an `end` event', async () => { let emitted; runner.addTask(foo); runner.on('end', (e) => { emitted = e; }); await runner.run(); expect(emitted).toBeObject(); expect(emitted.error).toBeNull(); }); it('fails and emits an error in the `end` event if necessary', async () => { let endError; let error; runner.addTask(badTask); runner.on('end', (e) => { endError = e.error; }); try { await runner.run(); } catch (e) { error = e; } expect(rethrower(endError)).toThrowError(); expect(endError).toBe(error); }); }); }); describe('running', () => { it('is true when the task runner is running', async () => { let running; runner.addTask( new Task({ name: 'checkRunning', func: () => { running = runner.running; return Promise.resolve(); }, }) ); await runner.run(); expect(running).toBeTrue(); }); it('is false when the task runner has not started', () => { runner.addTask(foo); expect(runner.running).toBeFalse(); }); it('is false after the task runner has finished', async () => { runner.addTask(foo); await runner.run(); expect(runner.running).toBeFalse(); }); }); describe('tasks', () => { it('is an object in which keys are task names and values are tasks', () => { let tasks; runner.addTask(foo); tasks = runner.tasks; expect(tasks).toBeObject(); expect(tasks.foo).toBe(foo); }); it('is an empty object if no tasks have been added', () => { expect(runner.tasks).toBeEmptyObject(); }); }); });