Skip to content

Commit

Permalink
CLI stdin/out/err streams; evented mocks. Ref yui#95.
Browse files Browse the repository at this point in the history
The CLI now expects to be provided with streams for:

 - stdin
 - stdout
 - stderr

This replaces the old options:

 - readableStream
 - writableStream
 - putsFn
 - errorFn

Removing putsFn and errorFn discourages usage of Node.js
stdio (e.g., console.*), which is one less thing to mock.
The CLI.prototype's puts() and error() methods still allow
for `printf`-like formatting by using util.format().

Create new mock Readable and Writable Streams for testing.

The MockWritableStream supports expect(), which will fire
a callback when the expected string is provided. This lets
us do async testing of the CLI.
  • Loading branch information
reid committed Oct 4, 2012
1 parent 291be89 commit f1c8d51
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 95 deletions.
7 changes: 3 additions & 4 deletions cli.js
Expand Up @@ -5,11 +5,10 @@
var CLI = require("./lib/cli").CLI;

var frontend = new CLI({
readableStream: process.stdin,
writableStream: process.stderr,
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
exitFn: process.exit,
errorFn: console.error,
putsFn: console.log
});

frontend.setupExceptionHandler();
Expand Down
55 changes: 22 additions & 33 deletions lib/cli.js
Expand Up @@ -21,8 +21,6 @@ var hubClient = require("./client");

var YetiEventEmitter2 = require("./events").EventEmitter2;

var isTTY = process.stderr.isTTY;

var good = "✓";
var bad = "✗";

Expand Down Expand Up @@ -135,10 +133,9 @@ var getLocalIP = exports.getLocalIP = (function () {
* @extends YetiEventEmitter2
* @param {Object} config Configuration.
* @param {Function} config.exitFn Handler when process exit is requested, 1-arity.
* @param {Function} config.putsFn Handler for stdout, expando arguments.
* @param {Function} config.errorFn Handler for stderr, expando arguments.
* @param {ReadableStream} config.readableStream Readable stream for creating a readline interface.
* @param {WritableStream} config.writableStream Writable stream for creating a readline interface.
* @param {ReadableStream} config.stdin Readable stream for creating a readline interface.
* @param {WritableStream} config.stdout Writable stream for creating a readline interface.
* @param {WritableStream} config.stderr Writable stream for creating a readline interface.
*/
function CLI(config) {
/**
Expand All @@ -149,40 +146,32 @@ function CLI(config) {
* @param {Number} Return code.
*/
this.exitFn = config.exitFn;

/**
* Fires for a line that should be printed to stdout.
*
* @property putsFn
* @type {Function}
* @param {String|Object} Multiple arguments.
*/
this.putsFn = config.putsFn;
/**
* Fires for a line that should be printed to stderr.
* For readline.
*
* @property errorFn
* @type {Function}
* @param {String|Object} Multiple arguments.
* @property stdin
* @type {ReadableStream}
*/
this.errorFn = config.errorFn;
this.stdin = config.stdin;

/**
* For readline.
*
* @property readableStream
* @type {ReadableStream}
* @property stdout
* @type {WritableStream}
*/
this.readableStream = config.readableStream;
this.stdout = config.stdout;

/**
* For readline.
*
* @property writableStream
* @property stderr
* @type {WritableStream}
*/
this.writableStream = config.writableStream;
this.stderr = config.stderr;

this.rl = readline.createInterface(this.readableStream, this.writableStream);
this.rl = readline.createInterface(this.stdin, this.stderr);
}

util.inherits(CLI, YetiEventEmitter2);
Expand All @@ -204,10 +193,9 @@ CLI.prototype.exit = function (code) {
* @private
*/
CLI.prototype.error = function () {
var args = Array.prototype.slice.apply(arguments);
// This is not an event because this may called
// when the event loop is already shut down.
this.errorFn.apply(this, args);
var args = Array.prototype.slice.apply(arguments),
formattedString = util.format.apply(util, args);
this.stderr.write(formattedString + "\n", "utf8");
};


Expand All @@ -218,8 +206,9 @@ CLI.prototype.error = function () {
* @private
*/
CLI.prototype.puts = function () {
var args = Array.prototype.slice.apply(arguments);
this.putsFn.apply(this, args);
var args = Array.prototype.slice.apply(arguments),
formattedString = util.format.apply(util, args);
this.stdout.write(formattedString + "\n", "utf8");
};

/**
Expand Down Expand Up @@ -248,7 +237,7 @@ CLI.prototype.setupExceptionHandler = function () {
err = err.stack;
}

if (isTTY) {
if (self.stderr.isTTY) {
message = [
color.red(bad + " Whoops!") + " " + err, "",
"If you believe this is a bug in Yeti, please report it.",
Expand Down Expand Up @@ -557,7 +546,7 @@ CLI.prototype.runBatch = function runBatch(options) {

self.rl.question("When ready, press Enter to begin testing.\n", function () {
self.rl.close();
self.readableStream.destroy();
self.stdin.destroy();
self.submitBatch(client, batchOptions);
});
}
Expand Down
44 changes: 19 additions & 25 deletions test/cli.js
Expand Up @@ -3,7 +3,7 @@
var vows = require("vows");
var assert = require("assert");

var MockWritableStream = require("./lib/writable-stream");
var streams = require("./lib/streams");

var cli = require("../lib/cli");
var YetiCLI = cli.CLI;
Expand All @@ -12,22 +12,20 @@ function cliTopic(fn) {
return function () {
var topic = {
fe: null,
writableStream: new MockWritableStream(),
log: new MockWritableStream(),
error: new MockWritableStream(),
stdin: new streams.MockReadableStream(),
stdout: new streams.MockWritableStream(),
stderr: new streams.MockWritableStream(),
exit: 0
};

function mockExit(code) {
topic.exit = code;
}


topic.fe = new YetiCLI({
writableStream: topic.writableStream,
readableStream: process.stdin, // FIXME
putsFn: topic.log.write.bind(topic.log),
errorFn: topic.error.write.bind(topic.error),
stdin: topic.stdin,
stdout: topic.stdout,
stderr: topic.stderr,
exitFn: mockExit
});

Expand All @@ -38,58 +36,54 @@ function cliTopic(fn) {
vows.describe("Yeti CLI").addBatch({
"A Yeti CLI without arguments": {
topic: cliTopic(function (topic) {
topic.stderr.expect("usage", this.callback);

topic.fe.route([
"node",
"cli.js"
]);

return topic;
}),
"returns usage on stderr": function (topic) {
assert.ok(topic.error.$store.indexOf("usage:") === 0);
assert.ok(topic.indexOf("usage:") === 0);
},
"returns helpful information on stderr": function (topic) {
assert.include(topic.error.$store, "launch the Yeti server");
assert.include(topic, "launch the Yeti server");
}
},
"A Yeti CLI with --server": {
topic: cliTopic(function (topic) {
topic.stderr.expect("started", this.callback);
topic.fe.route([
"node",
"cli.js",
"-s",
"-p", "9010"
]);

return topic;
}),
"returns startup message on stderr": function (topic) {
assert.ok(topic.error.$store.indexOf("Yeti Hub started") === 0);
assert.ok(topic.indexOf("Yeti Hub started") === 0);
}
},
"A Yeti CLI with files": {
topic: cliTopic(function (topic) {
var vow = this;
topic.stderr.expect("When ready", this.callback);

topic.fe.route([
"node",
"cli.js",
"-p", "9011",
"fixture/basic.html",
]);

setTimeout(function () {
vow.callback(null, topic);
}, 250);
}),
"prints hub creation message on stderr": function (topic) {
assert.ok(topic.error.$store.indexOf("Creating a Hub.") === 0);
assert.ok(topic.indexOf("Creating a Hub.") === 0);
},
"waits for agents to connect on stderr": function (topic) {
assert.include(topic.error.$store, "Waiting for agents to connect");
assert.include(topic.error.$store, "also available locally at");
assert.include(topic, "Waiting for agents to connect");
assert.include(topic, "also available locally at");
},
"prompts on the writableStream": function (topic) {
assert.include(topic.writableStream.$store, "When ready, press Enter");
assert.include(topic, "When ready, press Enter");
}
},
"parseArgv when given arguments": {
Expand Down
114 changes: 114 additions & 0 deletions test/lib/streams.js
@@ -0,0 +1,114 @@
"use strict";

/**
* @module streams
*/

var util = require("util");
var EventEmitter2 = require("../../lib/events").EventEmitter2;

function makeString(data) {
if (Buffer.isBuffer(data)) {
data = data.toString("utf8");
}

return data;
}

function WRITE(data) {
this.emit("data", makeString(data));
}

function NOOP() {}

/**
* @class MockReadableStream
* @constructor
* @extends YetiEventEmitter2
*/
function MockReadableStream() {
EventEmitter2.call(this);
}

util.inherits(MockReadableStream, EventEmitter2);

/**
* No-op.
*
* @method setEncoding
*/
MockReadableStream.prototype.setEncoding = NOOP;

/**
* No-op.
*
* @method resume
*/
MockReadableStream.prototype.resume = NOOP;

/**
* Emit the `data` event with first argument
* as a String.
*
* @method write
* @param {String|Buffer} data Data.
*/
MockReadableStream.prototype.write = WRITE;

/**
* @class MockWritableStream
* @constructor
* @extends YetiEventEmitter2
*/
function MockWritableStream() {
EventEmitter2.call(this);
}

util.inherits(MockWritableStream, EventEmitter2);

/**
* No-op.
*
* @method end
*/
MockWritableStream.prototype.end = NOOP;

/**
* Emit the `data` event with first argument
* as a String.
*
* @method write
* @param {String|Buffer} data Data.
*/
MockWritableStream.prototype.write = WRITE;

/**
* Call the given callback when expectedString
* is written to this stream. The callback recieves
* a string of all data written since the expect call.
*
* @method expect
* @param {String} expectedString Expected string.
* @param {Function} cb Callback.
* @param {null} cb.err Error for callback, always null.
* @param {String} cb.data All data written between expectedString
* appearing and calling expect.
*/
MockWritableStream.prototype.expect = function (expectedString, cb) {
var self = this,
dataEvents = [];

self.on("data", function ondata(data) {
data = makeString(data);

dataEvents.push(data);

if (data.indexOf(expectedString) !== -1) {
self.removeListener("data", ondata);
cb(null, dataEvents.join(""));
}
});
};

exports.MockReadableStream = MockReadableStream;
exports.MockWritableStream = MockWritableStream;
33 changes: 0 additions & 33 deletions test/lib/writable-stream.js

This file was deleted.

0 comments on commit f1c8d51

Please sign in to comment.