From 4c433d417e99884e132d7ea3495b4ff6b2b88676 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Sun, 2 Sep 2018 15:48:47 -0700 Subject: [PATCH] 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 --- src/cli/command.js | 33 +++++++++++++++++ src/cli/command.test.js | 78 ++++++++++++++++++++++++++++++++++++++++ src/cli/testUtil.js | 22 ++++++++++++ src/cli/testUtil.test.js | 69 +++++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 src/cli/command.js create mode 100644 src/cli/command.test.js create mode 100644 src/cli/testUtil.js create mode 100644 src/cli/testUtil.test.js diff --git a/src/cli/command.js b/src/cli/command.js new file mode 100644 index 0000000..829ff56 --- /dev/null +++ b/src/cli/command.js @@ -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, + std: Stdio +) => Promise; + +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); + } + }; +} diff --git a/src/cli/command.test.js b/src/cli/command.test.js new file mode 100644 index 0000000..7277da6 --- /dev/null +++ b/src/cli/command.test.js @@ -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"']]); + }); + }); +}); diff --git a/src/cli/testUtil.js b/src/cli/testUtil.js new file mode 100644 index 0000000..b6e0b1e --- /dev/null +++ b/src/cli/testUtil.js @@ -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 +): Promise<{| + +exitCode: ExitCode, + +stdout: $ReadOnlyArray, + +stderr: $ReadOnlyArray, +|}> { + 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}; +} diff --git a/src/cli/testUtil.test.js b/src/cli/testUtil.test.js new file mode 100644 index 0000000..a9e3508 --- /dev/null +++ b/src/cli/testUtil.test.js @@ -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"'], + }); + }); + }); +});