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:
parent
1f4a6395c8
commit
4c433d417e
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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"']]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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};
|
||||
}
|
|
@ -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"'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue