From 99199408071448e7ec5a856932d1671557d83456 Mon Sep 17 00:00:00 2001 From: Jean Lauliac Date: Wed, 14 Dec 2016 05:12:26 -0800 Subject: [PATCH] packager: Terminal abstraction to manage TTYs Reviewed By: cpojer Differential Revision: D4293146 fbshipit-source-id: 66e943b026197d293b5a518b4f97a0bced8d11bb --- react-packager/src/Logger/index.js | 3 +- react-packager/src/Server/index.js | 40 ++--- react-packager/src/lib/TransformCache.js | 9 +- .../src/lib/__tests__/terminal-test.js | 100 +++++++++++++ react-packager/src/lib/terminal.js | 137 ++++++++++++++++++ react-packager/src/node-haste/Module.js | 9 +- 6 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 react-packager/src/lib/__tests__/terminal-test.js create mode 100644 react-packager/src/lib/terminal.js diff --git a/react-packager/src/Logger/index.js b/react-packager/src/Logger/index.js index 84fe13b6..e93f1557 100644 --- a/react-packager/src/Logger/index.js +++ b/react-packager/src/Logger/index.js @@ -14,6 +14,7 @@ const chalk = require('chalk'); const os = require('os'); const pkgjson = require('../../../package.json'); +const terminal = require('../lib/terminal'); const {EventEmitter} = require('events'); @@ -134,7 +135,7 @@ function print( } // eslint-disable-next-line no-console-disallow - console.log(logEntryString); + terminal.log(logEntryString); return logEntry; } diff --git a/react-packager/src/Server/index.js b/react-packager/src/Server/index.js index b9f35063..b6bd9d36 100644 --- a/react-packager/src/Server/index.js +++ b/react-packager/src/Server/index.js @@ -15,13 +15,14 @@ const AssetServer = require('../AssetServer'); const getPlatformExtension = require('../node-haste').getPlatformExtension; const Bundler = require('../Bundler'); const MultipartResponse = require('./MultipartResponse'); -const ProgressBar = require('progress'); const SourceMapConsumer = require('source-map').SourceMapConsumer; const declareOpts = require('../lib/declareOpts'); const defaults = require('../../../defaults'); const mime = require('mime-types'); const path = require('path'); +const terminal = require('../lib/terminal'); +const throttle = require('lodash/throttle'); const url = require('url'); const debug = require('debug')('ReactNativePackager:Server'); @@ -454,7 +455,7 @@ class Server { e => { res.writeHead(500); res.end('Internal Error'); - console.log(e.stack); // eslint-disable-line no-console-disallow + terminal.log(e.stack); // eslint-disable-line no-console-disallow } ); } else { @@ -697,13 +698,15 @@ class Server { let consoleProgress = () => {}; if (process.stdout.isTTY && !this._opts.silent) { - const bar = new ProgressBar('transformed :current/:total (:percent)', { - complete: '=', - incomplete: ' ', - width: 40, - total: 1, - }); - consoleProgress = debouncedTick(bar); + const onProgress = (doneCount, totalCount) => { + const format = 'transformed %s/%s (%s%)'; + const percent = Math.floor(100 * doneCount / totalCount); + terminal.status(format, doneCount, totalCount, percent); + if (doneCount === totalCount) { + terminal.persistStatus(); + } + }; + consoleProgress = throttle(onProgress, 200); } const mres = MultipartResponse.wrap(req, res); @@ -959,23 +962,4 @@ function contentsEqual(array: Array, set: Set): boolean { return array.length === set.size && array.every(set.has, set); } -function debouncedTick(progressBar) { - let n = 0; - let start, total; - - return (_, t) => { - total = t; - n += 1; - if (start) { - if (progressBar.curr + n >= total || Date.now() - start > 200) { - progressBar.total = total; - progressBar.tick(n); - start = n = 0; - } - } else { - start = Date.now(); - } - }; -} - module.exports = Server; diff --git a/react-packager/src/lib/TransformCache.js b/react-packager/src/lib/TransformCache.js index b6638f75..90e48eb4 100644 --- a/react-packager/src/lib/TransformCache.js +++ b/react-packager/src/lib/TransformCache.js @@ -22,6 +22,7 @@ const jsonStableStringify = require('json-stable-stringify'); const mkdirp = require('mkdirp'); const path = require('path'); const rimraf = require('rimraf'); +const terminal = require('../lib/terminal'); const toFixedHex = require('./toFixedHex'); const writeFileAtomicSync = require('write-file-atomic').sync; @@ -189,18 +190,18 @@ const GARBAGE_COLLECTOR = new (class GarbageCollector { try { this._collectSync(); } catch (error) { - console.error(error.stack); - console.error( + terminal.log(error.stack); + terminal.log( 'Error: Cleaning up the cache folder failed. Continuing anyway.', ); - console.error('The cache folder is: %s', getCacheDirPath()); + terminal.log('The cache folder is: %s', getCacheDirPath()); } this._lastCollected = Date.now(); } _resetCache() { rimraf.sync(getCacheDirPath()); - console.log('Warning: The transform cache was reset.'); + terminal.log('Warning: The transform cache was reset.'); this._cacheWasReset = true; this._lastCollected = Date.now(); } diff --git a/react-packager/src/lib/__tests__/terminal-test.js b/react-packager/src/lib/__tests__/terminal-test.js new file mode 100644 index 00000000..5a86a68a --- /dev/null +++ b/react-packager/src/lib/__tests__/terminal-test.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +'use strict'; + +jest.dontMock('../terminal'); + +jest.mock('readline', () => ({ + moveCursor: (stream, dx, dy) => { + const {cursor, columns} = stream; + stream.cursor = Math.max(cursor - cursor % columns, cursor + dx) + dy * columns; + }, + clearLine: (stream, dir) => { + if (dir !== 0) {throw new Error('unsupported');} + const {cursor, columns} = stream; + const curLine = cursor - cursor % columns; + const nextLine = curLine + columns; + for (var i = curLine; i < nextLine; ++i) { + stream.buffer[i] = ' '; + } + }, +})); + +describe('terminal', () => { + + beforeEach(() => { + jest.resetModules(); + }); + + function prepare(isTTY) { + const {Terminal} = require('../terminal'); + const lines = 10; + const columns = 10; + const stream = Object.create( + isTTY ? require('tty').WriteStream.prototype : require('net').Socket, + ); + Object.assign(stream, { + cursor: 0, + buffer: ' '.repeat(columns * lines).split(''), + columns, + lines, + write(str) { + for (let i = 0; i < str.length; ++i) { + if (str[i] === '\n') { + this.cursor = this.cursor - (this.cursor % columns) + columns; + } else { + this.buffer[this.cursor] = str[i]; + ++this.cursor; + } + } + }, + }); + return {stream, terminal: new Terminal(stream)}; + } + + it('is not printing status to non-interactive terminal', () => { + const {stream, terminal} = prepare(false); + terminal.log('foo %s', 'smth'); + terminal.status('status'); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo smth bar'); + }); + + it('updates status when logging, single line', () => { + const {stream, terminal} = prepare(true); + terminal.log('foo'); + terminal.status('status'); + terminal.status('status2'); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo bar status2'); + }); + + it('updates status when logging, multi-line', () => { + const {stream, terminal} = prepare(true); + terminal.log('foo'); + terminal.status('status\nanother'); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo bar status another'); + }); + + it('persists status', () => { + const {stream, terminal} = prepare(true); + terminal.log('foo'); + terminal.status('status'); + terminal.persistStatus(); + terminal.log('bar'); + expect(stream.buffer.join('').trim()) + .toEqual('foo status bar'); + }); + +}); diff --git a/react-packager/src/lib/terminal.js b/react-packager/src/lib/terminal.js new file mode 100644 index 00000000..8ab19ef6 --- /dev/null +++ b/react-packager/src/lib/terminal.js @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +'use strict'; + +const readline = require('readline'); +const tty = require('tty'); +const util = require('util'); + +/** + * Clear some text that was previously printed on an interactive stream, + * without trailing newline character (so we have to move back to the + * beginning of the line). + */ +function clearStringBackwards(stream: tty.WriteStream, str: string): void { + readline.moveCursor(stream, -stream.columns, 0); + readline.clearLine(stream, 0); + let lineCount = (str.match(/\n/g) || []).length; + while (lineCount > 0) { + readline.moveCursor(stream, 0, -1); + readline.clearLine(stream, 0); + --lineCount; + } +} + +/** + * We don't just print things to the console, sometimes we also want to show + * and update progress. This utility just ensures the output stays neat: no + * missing newlines, no mangled log lines. + * + * const terminal = Terminal.default; + * terminal.status('Updating... 38%'); + * terminal.log('warning: Something happened.'); + * terminal.status('Updating, done.'); + * terminal.persistStatus(); + * + * The final output: + * + * warning: Something happened. + * Updating, done. + * + * Without the status feature, we may get a mangled output: + * + * Updating... 38%warning: Something happened. + * Updating, done. + * + * This is meant to be user-readable and TTY-oriented. We use stdout by default + * because it's more about status information than diagnostics/errors (stderr). + * + * Do not add any higher-level functionality in this class such as "warning" and + * "error" printers, as it is not meant for formatting/reporting. It has the + * single responsibility of handling status messages. + */ +class Terminal { + + _statusStr: string; + _stream: net$Socket; + + constructor(stream: net$Socket) { + this._stream = stream; + this._statusStr = ''; + } + + /** + * Same as status() without the formatting capabilities. We just clear and + * rewrite with the new status. If the stream is non-interactive we still + * keep track of the string so that `persistStatus` works. + */ + _setStatus(str: string): string { + const {_statusStr, _stream} = this; + if (_statusStr !== str && _stream instanceof tty.WriteStream) { + clearStringBackwards(_stream, _statusStr); + _stream.write(str); + } + this._statusStr = str; + return _statusStr; + } + + /** + * Shows some text that is meant to be overriden later. Return the previous + * status that was shown and is no more. Calling `status()` with no argument + * removes the status altogether. The status is never shown in a + * non-interactive terminal: for example, if the output is redirected to a + * file, then we don't care too much about having a progress bar. + */ + status(format: string, ...args: Array): string { + return this._setStatus(util.format(format, ...args)); + } + + /** + * Similar to `console.log`, except it moves the status/progress text out of + * the way correctly. In non-interactive terminals this is the same as + * `console.log`. + */ + log(format: string, ...args: Array): void { + const oldStatus = this._setStatus(''); + this._stream.write(util.format(format, ...args) + '\n'); + this._setStatus(oldStatus); + } + + /** + * Log the current status and start from scratch. This is useful if the last + * status was the last one of a series of updates. + */ + persistStatus(): void { + return this.log(this.status('')); + } + +} + +/** + * On the same pattern as node.js `console` module, we export the stdout-based + * terminal at the top-level, but provide access to the Terminal class as a + * field (so it can be used, for instance, with stderr). + */ +class GlobalTerminal extends Terminal { + + Terminal: Class; + + constructor() { + /* $FlowFixMe: Flow is wrong, Node.js docs specify that process.stderr is an + * instance of a net.Socket (a local socket, not network). */ + super(process.stderr); + this.Terminal = Terminal; + } + +} + +module.exports = new GlobalTerminal(); diff --git a/react-packager/src/node-haste/Module.js b/react-packager/src/node-haste/Module.js index 0435843f..7bf4976b 100644 --- a/react-packager/src/node-haste/Module.js +++ b/react-packager/src/node-haste/Module.js @@ -21,6 +21,7 @@ const fs = require('fs'); const invariant = require('invariant'); const isAbsolutePath = require('absolute-path'); const jsonStableStringify = require('json-stable-stringify'); +const terminal = require('../lib/terminal'); const {join: joinPath, relative: relativePath, extname} = require('path'); @@ -266,13 +267,13 @@ class Module { } globalCache.fetch(cacheProps, (globalCacheError, globalCachedResult) => { if (globalCacheError != null && Module._globalCacheRetries > 0) { - console.log(chalk.red( - '\nWarning: the global cache failed with error:', + terminal.log(chalk.red( + 'Warning: the global cache failed with error:', )); - console.log(chalk.red(globalCacheError.stack)); + terminal.log(chalk.red(globalCacheError.stack)); Module._globalCacheRetries--; if (Module._globalCacheRetries <= 0) { - console.log(chalk.red( + terminal.log(chalk.red( 'No more retries, the global cache will be disabled for the ' + 'remainder of the transformation.', ));