cli: add command infrastructure and test utils (#740)

Summary:
This commit introduces the notion of a `Command`, which is simply a
function that takes command-line arguments and interacts with the real
world. This infrastructure will enable us to write a well-tested CLI.

The `Command` interface is asynchronous because commands like `load`
need to block on promise resolution (for loading GitHub and Git data).
This is annoying for testing, but does not actually appear to be a
problem in practice.

Test Plan:
Unit tests added. See later commits for real-world usage.

wchargin-branch: cli-command-infrastructure
This commit is contained in:
William Chargin 2018-09-02 15:48:47 -07:00 committed by GitHub
parent 1f4a6395c8
commit 4c433d417e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 202 additions and 0 deletions

33
src/cli/command.js Normal file
View File

@ -0,0 +1,33 @@
// @flow
export type ExitCode = number;
export interface Stdio {
/**
* Print a line to stdout. A newline will be added.
*/
out(line: string): void;
/**
* Print a line to stderr. A newline will be added.
*/
err(line: string): void;
}
export type Command = (
args: $ReadOnlyArray<string>,
std: Stdio
) => Promise<ExitCode>;
export function handlingErrors(command: Command): Command {
return async (args, stdio) => {
function die(e) {
stdio.err(e instanceof Error ? e.stack : JSON.stringify(e));
return Promise.resolve(1);
}
try {
return command(args, stdio).catch((e) => die(e));
} catch (e) {
return die(e);
}
};
}

78
src/cli/command.test.js Normal file
View File

@ -0,0 +1,78 @@
// @flow
import {type Command, handlingErrors} from "./command";
describe("cli/command", () => {
describe("handlingErrors", () => {
it("passes through arguments appropriately", async () => {
const expectedArgs = ["arg", "arg", "arg"];
const expectedStdio = {out: () => {}, err: () => {}};
let called = false;
const cmd: Command = async (args, stdio) => {
expect(args).toBe(expectedArgs);
expect(args).toEqual(["arg", "arg", "arg"]);
expect(stdio).toBe(expectedStdio);
called = true;
return 0;
};
await handlingErrors(cmd)(expectedArgs, expectedStdio);
expect(called).toBe(true);
});
it("passes through a return value", async () => {
const cmd: Command = (args) => Promise.resolve(parseInt(args[0], 10));
const stdio = {out: () => {}, err: () => {}};
expect(await handlingErrors(cmd)(["0"], stdio)).toBe(0);
expect(await handlingErrors(cmd)(["1"], stdio)).toBe(1);
expect(await handlingErrors(cmd)(["2"], stdio)).toBe(2);
});
it("handles a thrown Error", async () => {
const stdio = {out: jest.fn(), err: jest.fn()};
const cmd: Command = () => {
throw new Error("wat");
};
const exitCode = await handlingErrors(cmd)([], stdio);
expect(exitCode).toBe(1);
expect(stdio.out).toHaveBeenCalledTimes(0);
expect(stdio.err).toHaveBeenCalledTimes(1);
expect(stdio.err.mock.calls[0]).toHaveLength(1);
expect(stdio.err.mock.calls[0][0]).toMatch(/^Error: wat\n *at cmd/);
});
it("handles a thrown string", async () => {
const stdio = {out: jest.fn(), err: jest.fn()};
const cmd: Command = () => {
// This is bad form, but we should try not to die in case clients do it.
// eslint-disable-next-line no-throw-literal
throw "wat";
};
const exitCode = await handlingErrors(cmd)([], stdio);
expect(exitCode).toBe(1);
expect(stdio.out).toHaveBeenCalledTimes(0);
expect(stdio.err).toHaveBeenCalledTimes(1);
expect(stdio.err.mock.calls).toEqual([['"wat"']]);
});
it("handles a rejection with an Error", async () => {
const stdio = {out: jest.fn(), err: jest.fn()};
const cmd: Command = () => Promise.reject(new Error("wat"));
const exitCode = await handlingErrors(cmd)([], stdio);
expect(exitCode).toBe(1);
expect(stdio.out).toHaveBeenCalledTimes(0);
expect(stdio.err).toHaveBeenCalledTimes(1);
expect(stdio.err.mock.calls[0]).toHaveLength(1);
expect(stdio.err.mock.calls[0][0]).toMatch(/^Error: wat\n *at cmd/);
});
it("handles rejection with a string", async () => {
const stdio = {out: jest.fn(), err: jest.fn()};
const cmd: Command = () => Promise.reject("wat");
const exitCode = await handlingErrors(cmd)([], stdio);
expect(exitCode).toBe(1);
expect(stdio.out).toHaveBeenCalledTimes(0);
expect(stdio.err).toHaveBeenCalledTimes(1);
expect(stdio.err.mock.calls).toEqual([['"wat"']]);
});
});
});

22
src/cli/testUtil.js Normal file
View File

@ -0,0 +1,22 @@
// @flow
import {type Command, type ExitCode, handlingErrors} from "./command";
// Run a command, capturing its stdout, stderr, and exit code. A thrown
// exception will be handled as with `handlingErrors`.
export async function run(
command: Command,
args: $ReadOnlyArray<string>
): Promise<{|
+exitCode: ExitCode,
+stdout: $ReadOnlyArray<string>,
+stderr: $ReadOnlyArray<string>,
|}> {
const stdout = [];
const stderr = [];
const exitCode = await handlingErrors(command)(args, {
out: (x) => void stdout.push(x),
err: (x) => void stderr.push(x),
});
return {exitCode, stdout, stderr};
}

69
src/cli/testUtil.test.js Normal file
View File

@ -0,0 +1,69 @@
// @flow
import type {Command} from "./command";
import {run} from "./testUtil";
describe("cli/testUtil", () => {
const testCommand: Command = async (args, std) => {
switch (args[0]) {
case "good":
std.out("all");
std.out("is");
std.out("well");
return 0;
case "bad":
std.out("something's");
std.err("not");
std.out("right");
return 1;
case "throw":
std.out("???");
std.err("what is going on");
throw new Error(args[0]);
case "reject":
std.out("!!!");
std.err("something is going on!");
return Promise.reject(args[0]);
default:
console.error("Actually shouldn't happen");
return 2;
}
};
describe("run", () => {
it("captures stdout with a successful command", async () => {
expect(await run(testCommand, ["good"])).toEqual({
exitCode: 0,
stdout: ["all", "is", "well"],
stderr: [],
});
});
it("captures stderr with a failed command", async () => {
expect(await run(testCommand, ["bad"])).toEqual({
exitCode: 1,
stdout: ["something's", "right"],
stderr: ["not"],
});
});
it("handles exceptions", async () => {
expect(await run(testCommand, ["throw"])).toEqual({
exitCode: 1,
stdout: ["???"],
stderr: [
"what is going on",
expect.stringMatching(/^Error: throw\n *at testCommand/),
],
});
});
it("handles exceptions", async () => {
expect(await run(testCommand, ["reject"])).toEqual({
exitCode: 1,
stdout: ["!!!"],
stderr: ["something is going on!", '"reject"'],
});
});
});
});