diff --git a/lib/task.js b/lib/task.js index ab20417..07e549b 100644 --- a/lib/task.js +++ b/lib/task.js @@ -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; } 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); } diff --git a/package.json b/package.json index 745cb7a..45e56a9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/readme.md b/readme.md index 76a4e77..93d5890 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/test/fixtures/renderer-utils.js b/test/fixtures/renderer-utils.js new file mode 100644 index 0000000..a7eee8e --- /dev/null +++ b/test/fixtures/renderer-utils.js @@ -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 ' '; +}; diff --git a/test/fixtures/task-with-input.js b/test/fixtures/task-with-input.js new file mode 100755 index 0000000..5075366 --- /dev/null +++ b/test/fixtures/task-with-input.js @@ -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); +})(); diff --git a/test/fixtures/update-renderer.js b/test/fixtures/update-renderer.js new file mode 100644 index 0000000..d6af5bd --- /dev/null +++ b/test/fixtures/update-renderer.js @@ -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; diff --git a/test/suspend.js b/test/suspend.js new file mode 100644 index 0000000..921d817 --- /dev/null +++ b/test/suspend.js @@ -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}); +}); diff --git a/test/task-with-input.js b/test/task-with-input.js new file mode 100644 index 0000000..d9132d1 --- /dev/null +++ b/test/task-with-input.js @@ -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');