Skip to content
Open
9 changes: 9 additions & 0 deletions lib/task.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,21 @@ class Task extends Subject {
this.title = task.title;
this.skip = task.skip || defaultSkipFn;
this.task = task.task;
this.taskOptions = task.options;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is moot. You can already access it as this.task.options.

Copy link
Copy Markdown
Author

@bunysae bunysae Sep 14, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but this.task can be just a function. task.options refers to input-variable task given in the constructor and not to this.task. We will need here a separate variable to store the options.

}

get subtasks() {
return this._subtasks;
}

shouldSuspendUpdateRenderer() {
if (this.isEnabled() && this.isPending() && !this.isCompleted() && this.taskOptions) {
return this.taskOptions.suspendUpdateRenderer;
}

return false;
}

get state() {
return state.toString(this._state);
}
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@
"clinton": "*",
"codecov": "^3.1.0",
"delay": "^4.1.0",
"elegant-spinner": "^1.0.1",
"hook-std": "^1.1.0",
"lint-staged": "^8.0.5",
"log-symbols": "^2.2.0",
"mockery": "^2.1.0",
"nyc": "^13.1.0",
"pre-commit": "^1.2.2",
"split": "^1.0.1",
Expand Down
22 changes: 22 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,28 @@ tasks.run();
```


## Rendering options

By default, the [`listr-update-renderer`](https://github.com/SamVerschueren/listr-update-renderer) renderer is used. The renderer updates the state of the tasks every 100ms. When the `suspendUpdateRenderer` option is supplied, the state updates can be suspended while the task is running. After the task is completed, the renderer will resume updating the state of the tasks.

```js
const tasks = new Listr([
{
title: 'Encrypt gpg-file',
task: () => execa('gpg', ['-e', 'file']),
options: {suspendUpdateRenderer: true}
},
{
title: 'Remove the unencrypted file',
task: () => execa('rm', ['file'])
}
]);

tasks.run();
```

The `suspendUpdateRenderer` option is useful for tasks which receive user input while running, as without it the input will be ignored by the renderer.

## Custom renderers

It's possible to write custom renderers for Listr. A renderer is an ES6 class that accepts the tasks that it should render, and the Listr options object. It has two methods, the `render` method which is called when it should start rendering, and the `end` method. The `end` method is called when all the tasks are completed or if a task failed. If a task failed, the error object is passed in via an argument.
Expand Down
34 changes: 34 additions & 0 deletions test/fixtures/renderer-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';
const chalk = require('chalk');
const logSymbols = require('log-symbols');
const figures = require('figures');
const elegantSpinner = require('elegant-spinner');

const pointer = chalk.yellow(figures.pointer);
const skipped = chalk.yellow(figures.arrowDown);

exports.isDefined = x => x !== null && x !== undefined;

exports.getSymbol = (task, options) => {
if (!task.spinner) {
task.spinner = elegantSpinner();
}

if (task.isPending()) {
return options.showSubtasks !== false && task.subtasks.length > 0 ? pointer : chalk.yellow(task.spinner());
}

if (task.isCompleted()) {
return logSymbols.success;
}

if (task.hasFailed()) {
return task.subtasks.length > 0 ? pointer : logSymbols.error;
}

if (task.isSkipped()) {
return skipped;
}

return ' ';
};
40 changes: 40 additions & 0 deletions test/fixtures/task-with-input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env node
'use strict';
const Listr = require('../..');
const updateRenderer = require('./update-renderer');

function getTasks(suspendUpdateRenderer) {
const task = () => {
return new Promise((resolve, reject) => {
console.log('Continue?');
process.stdin.on('data', chunk => {
const received = chunk.toString().replace('\n', '');
if (received === 'yes') {
resolve(received);
} else {
reject(new Error(`invalid input ${received}`));
}
});
});
};

const result = new Listr([
{
title: `receive user input from task with renderer is ${suspendUpdateRenderer ? 'suspended' : 'running'}`,
task,
options: {suspendUpdateRenderer}
}
]);
result.setRenderer(updateRenderer);
return result;
}

(async () => {
let suspendUpdateRenderer = false;
if (process.argv[2] === 'true') {
suspendUpdateRenderer = true;
}

await getTasks(suspendUpdateRenderer).run();
process.exit(0);
})();
99 changes: 99 additions & 0 deletions test/fixtures/update-renderer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use strict';
const logUpdate = require('log-update');
const chalk = require('chalk');
const figures = require('figures');
const indentString = require('indent-string');
const cliTruncate = require('cli-truncate');
const stripAnsi = require('strip-ansi');
const utils = require('./renderer-utils');

const renderHelper = (tasks, options, level) => {
level = level || 0;

let output = [];

for (const task of tasks) {
if (task.isEnabled()) {
const skipped = task.isSkipped() ? ` ${chalk.dim('[skipped]')}` : '';

output.push(indentString(` ${utils.getSymbol(task, options)} ${task.title}${skipped}`, level, ' '));

if ((task.isPending() || task.isSkipped() || task.hasFailed()) && utils.isDefined(task.output)) {
let data = task.output;

if (typeof data === 'string') {
data = stripAnsi(data.trim().split('\n').filter(Boolean).pop());

if (data === '') { // eslint-disable-line max-depth
data = undefined;
}
}

if (utils.isDefined(data)) {
const out = indentString(`${figures.arrowRight} ${data}`, level, ' ');
output.push(` ${chalk.gray(cliTruncate(out, process.stdout.columns - 3))}`);
}
}

if ((task.isPending() || task.hasFailed() || options.collapse === false) && (task.hasFailed() || options.showSubtasks !== false) && task.subtasks.length > 0) {
output = output.concat(renderHelper(task.subtasks, options, level + 1));
}
}
}

return output.join('\n');
};

const shouldSuspendUpdateRenderer = tasks => {
return tasks.some(task => task.shouldSuspendUpdateRenderer());
};

const render = (tasks, options) => {
if (!shouldSuspendUpdateRenderer(tasks)) {
logUpdate(renderHelper(tasks, options));
}
};

class UpdateRenderer {
constructor(tasks, options) {
this._tasks = tasks;
this._options = {
showSubtasks: true,
collapse: true,
clearOutput: false,
...options
};
}

static get nonTTY() {
return true;
}

render() {
if (this._id) {
// Do not render if we are already rendering
return;
}

this._id = setInterval(() => {
render(this._tasks, this._options);
}, 100);
}

end(err) {
if (this._id) {
clearInterval(this._id);
this._id = undefined;
}

render(this._tasks, this._options);

if (this._options.clearOutput && err === undefined) {
logUpdate.clear();
} else {
logUpdate.done();
}
}
}

module.exports = UpdateRenderer;
71 changes: 71 additions & 0 deletions test/suspend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const test = require('ava');
const mockery = require('mockery');
const elegantSpinner = require('elegant-spinner');

function getTasks() {
const Listr = require('..');
const tasks = new Listr([
{
title: 'first',
task: () => {
return new Promise(resolve => {
setTimeout(() => {
resolve('predefined output');
}, 150);
});
}
},
{
title: 'second',
task: () => {
return new Promise(resolve => {
setTimeout(() => {
resolve('predefined output');
}, 300);
});
},
options: {suspendUpdateRenderer: true}
},
{
title: 'last',
task: () => {
return new Promise(resolve => {
setTimeout(() => {
resolve('predefined output');
}, 100);
});
}
}
]);
tasks.setRenderer(require('./fixtures/update-renderer'));

return tasks;
}

test('should not erase output from suspended task, as long as this task is running', async t => {
const countOutputIsErasedWhenTaskRunning = {first: 0, second: 0, last: 0};

const logUpdate = output => {
const taskPendingPattern = new RegExp(elegantSpinner.frames.join('|'));
const tasks = output.split('\n');
for (const task of tasks) {
if (task.search(taskPendingPattern) > 0) {
const title = task.match(/first|second|last/);
countOutputIsErasedWhenTaskRunning[title]++;
}
}
};

logUpdate.done = () => {};

mockery.registerAllowable('..');
mockery.registerMock('log-update', logUpdate);
mockery.enable({useCleanCache: true, warnOnUnregistered: false});

await getTasks().run();

mockery.deregisterAll();
mockery.disable();

t.deepEqual(countOutputIsErasedWhenTaskRunning, {first: 1, second: 0, last: 1});
});
26 changes: 26 additions & 0 deletions test/task-with-input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';
const {spawn} = require('child_process');
const path = require('path');
const test = require('ava');

const executeTask = async (t, suspendUpdateRenderer) => {
const promise = new Promise((resolve, reject) => {
const childProcess = spawn('./task-with-input.js', [suspendUpdateRenderer], {cwd: path.resolve('test', 'fixtures')});
childProcess.stdout.pipe(process.stdout);
childProcess.on('close', code => {
if (code !== 0) {
reject(new Error(`exit code ${code}`));
}

resolve();
});
setTimeout(() => {
childProcess.stdin.write('yes');
}, 50);
});
await promise;
t.pass();
};

test('should receive user input from task with running renderer', executeTask);
test('should receive user input from task with suspended renderer', executeTask, 'true');