packager: Terminal abstraction to manage TTYs

Reviewed By: cpojer

Differential Revision: D4293146

fbshipit-source-id: 66e943b026197d293b5a518b4f97a0bced8d11bb
This commit is contained in:
Jean Lauliac 2016-12-14 05:12:26 -08:00 committed by Facebook Github Bot
parent f8f70d2275
commit 26ed94c0fd
7 changed files with 261 additions and 38 deletions

View File

@ -167,7 +167,6 @@
"opn": "^3.0.2",
"optimist": "^0.6.1",
"plist": "^1.2.0",
"progress": "^1.1.8",
"promise": "^7.1.1",
"react-clone-referenced-element": "^1.0.1",
"react-timer-mixin": "^0.13.2",

View File

@ -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;
}

View File

@ -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<mixed>, set: Set<mixed>): 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;

View File

@ -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();
}

View File

@ -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');
});
});

View File

@ -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<mixed>): 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<mixed>): 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<Terminal>;
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();

View File

@ -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.',
));