diff --git a/src/packages/cli/package.json b/src/packages/cli/package.json index 4d0803c8dc..1a283de315 100644 --- a/src/packages/cli/package.json +++ b/src/packages/cli/package.json @@ -30,7 +30,7 @@ }, "scripts": { "tsc": "tsc --build", - "test": "nyc npm run mocha", + "test": "nyc --reporter lcov npm run mocha", "mocha": "cross-env TS_NODE_FILES=true mocha --exit --check-leaks --throw-deprecation --trace-warnings --require ts-node/register 'tests/**/*.test.ts'", "start": "cross-env node --require ts-node/register --inspect src/cli.ts" }, diff --git a/src/packages/cli/src/args.ts b/src/packages/cli/src/args.ts index 5899d980c0..a36fae8690 100644 --- a/src/packages/cli/src/args.ts +++ b/src/packages/cli/src/args.ts @@ -1,5 +1,10 @@ +// `import Yargs from "yargs"` is the Yargs singleton and namespace +// `import yargs from "yargs/yargs"` is the non-singleton interface +// See https://github.com/yargs/yargs/issues/1648 +import Yargs, { Options } from "yargs"; +import yargs from "yargs/yargs"; + import { TruffleColors } from "@ganache/colors"; -import yargs, { Options } from "yargs"; import { DefaultFlavor, FilecoinFlavorName, @@ -26,7 +31,7 @@ marked.setOptions({ }) }); -const wrapWidth = Math.min(120, yargs.terminalWidth()); +const wrapWidth = Math.min(120, Yargs.terminalWidth()); const NEED_HELP = "Need more help? Reach out to the Truffle community at"; const COMMUNITY_LINK = "https://trfl.io/support"; @@ -43,7 +48,7 @@ const highlight = (t: string) => unescapeEntities(marked.parseInline(t)); const center = (str: string) => " ".repeat(Math.max(0, Math.floor((wrapWidth - str.length) / 2))) + str; -const addAliases = (args: yargs.Argv<{}>, aliases: string[], key: string) => { +const addAliases = (args: Yargs.Argv<{}>, aliases: string[], key: string) => { const options = { hidden: true, alias: key }; return aliases.reduce((args, a) => args.option(a, options), args); }; @@ -54,7 +59,7 @@ function processOption( group: string, option: string, optionObj: Definitions[string], - argv: yargs.Argv, + argv: Yargs.Argv, flavor: string ) { if (optionObj.disableInCLI !== true) { @@ -122,7 +127,7 @@ function applyDefaults( flavorDefaults: | typeof DefaultOptionsByName[keyof typeof DefaultOptionsByName] | typeof _DefaultServerOptions, - flavorArgs: yargs.Argv<{}>, + flavorArgs: Yargs.Argv<{}>, flavor: keyof typeof DefaultOptionsByName ) { for (const category in flavorDefaults) { @@ -160,8 +165,9 @@ export default function ( // disable dot-notation because yargs just can't coerce args properly... // ...on purpose! https://github.com/yargs/yargs/issues/1021#issuecomment-352324693 - yargs + const yargsParser = yargs() .parserConfiguration({ "dot-notation": false }) + .exitProcess(false) .strict() .usage(versionUsageOutputText) .epilogue( @@ -188,12 +194,9 @@ export default function ( command = ["$0", flavor]; defaultPort = 8545; break; - default: - command = flavor; - defaultPort = 8545; } - yargs.command( + yargsParser.command( command, chalk`Use the {bold ${flavor}} flavor of Ganache`, flavorArgs => { @@ -214,14 +217,19 @@ export default function ( description: chalk`Port to listen on.${EOL}{dim deprecated aliases: --port}${EOL}`, alias: ["p", "port"], type: "number", - default: defaultPort + default: defaultPort, + // `string: true` to allow raw value to be used in validation below + // (otherwise string values becomes NaN) + string: true, + coerce: port => (isFinite(port) ? +port : port) }) .check(argv => { const { "server.port": port, "server.host": host } = argv; - if (port < 1 || port > 65535) { - throw new Error(`Invalid port number '${port}'`); + if (!isFinite(port) || port < 1 || port > 65535) { + throw new Error( + `Port should be >= 0 and < 65536. Received ${port}.` + ); } - if (host.trim() === "") { throw new Error("Cannot leave host blank; please provide a host"); } @@ -244,7 +252,7 @@ export default function ( ); } - yargs + yargsParser .command( "instances", highlight( @@ -272,6 +280,14 @@ export default function ( stopArgs.action = "stop"; } ) + .check(instancesArgs => { + if (instancesArgs["_"].length <= 1) { + throw new Error( + "No sub-command given. See `ganache instances --help` for more information." + ); + } + return true; + }) .version(false); } ) @@ -280,7 +296,7 @@ export default function ( .wrap(wrapWidth) .version(version); - const parsedArgs = yargs.parse(rawArgs); + const parsedArgs = yargsParser.parse(rawArgs); let finalArgs: GanacheArgs; if (parsedArgs.action === "stop") { @@ -308,7 +324,7 @@ export default function ( >) }; } else { - throw new Error(`Unknown action: ${parsedArgs.action}`); + finalArgs = { action: "none" }; } return finalArgs; diff --git a/src/packages/cli/src/cli.ts b/src/packages/cli/src/cli.ts index 8444315505..9c293607ae 100644 --- a/src/packages/cli/src/cli.ts +++ b/src/packages/cli/src/cli.ts @@ -17,6 +17,7 @@ import { import { TruffleColors } from "@ganache/colors"; import { table } from "table"; import chalk from "chalk"; +import { GanacheArgs } from "./types"; const logAndForceExit = (messages: any[], exitCode = 0) => { // https://nodejs.org/api/process.html#process_process_exit_code @@ -60,7 +61,11 @@ const detailedVersion = `ganache v${version} (@ganache/cli: ${cliVersion}, @gana const isDocker = "DOCKER" in process.env && process.env.DOCKER.toLowerCase() === "true"; -const argv = args(detailedVersion, isDocker); +let argv: GanacheArgs; +try { + argv = args(detailedVersion, isDocker); +} catch (err: any) {} + if (argv.action === "start") { const flavor = argv.flavor; const cliSettings = argv.server; diff --git a/src/packages/cli/src/process-name.ts b/src/packages/cli/src/process-name.ts index 0680219a74..91b4394ad1 100644 --- a/src/packages/cli/src/process-name.ts +++ b/src/packages/cli/src/process-name.ts @@ -1,5 +1,5 @@ -function pick(source: string[]) { - const partIndex = Math.floor(Math.random() * source.length); +function pick(source: string[], random: () => number) { + const partIndex = Math.floor(random() * source.length); return source[partIndex]; } /** @@ -7,14 +7,17 @@ function pick(source: string[]) { * generated from an adjective, a flavor and a type of desert, in the form of * `__`, eg., `salted_caramel_ganache`. */ -export default function createInstanceName() { - const name = [adjectives, flavors, kinds].map(pick).join("_"); +export default function createInstanceName(random: () => number = Math.random) { + const name = [adjectives, flavors, kinds] + .map(source => pick(source, random)) + .join("_"); return name; } const adjectives = [ "baked", "candied", + "creamy", "deepfried", "frozen", "hot", @@ -48,7 +51,7 @@ const flavors = [ "orange", "peanut", "plum", - "poppy-seed", + "poppyseed", "rhubarb", "strawberry", "sugar", diff --git a/src/packages/cli/src/types.ts b/src/packages/cli/src/types.ts index 1cb603e559..d6ad328a73 100644 --- a/src/packages/cli/src/types.ts +++ b/src/packages/cli/src/types.ts @@ -6,7 +6,7 @@ type CliServerOptions = { port: number; }; -type Action = "start" | "start-detached" | "list" | "stop"; +type Action = "start" | "start-detached" | "list" | "stop" | "none"; type AbstractArgs = { action: TAction; @@ -21,6 +21,7 @@ export type StartArgs = export type GanacheArgs = | (AbstractArgs<"stop"> & { name: string }) | AbstractArgs<"list"> + | AbstractArgs<"none"> | StartArgs; export type CliSettings = CliServerOptions; diff --git a/src/packages/cli/tests/args.test.ts b/src/packages/cli/tests/args.test.ts index 4a8493d5d8..a76445b26a 100644 --- a/src/packages/cli/tests/args.test.ts +++ b/src/packages/cli/tests/args.test.ts @@ -38,7 +38,7 @@ describe("args", () => { }); }); - it("should remove arguments who are kebab-cased", () => { + it("should remove arguments which are kebab-cased", () => { const input = { "namespace.name": "value", "namespace.kebab-case": "value", @@ -54,10 +54,134 @@ describe("args", () => { }); describe("parse args", () => { - describe("detach", () => { - const versionString = "Version string"; - const isDocker = false; + const versionString = "Version string"; + const isDocker = false; + + describe("help", () => { + it("should accept a help parameter", () => { + const rawArgs = ["--help"]; + const options = args(versionString, isDocker, rawArgs); + + assert.deepStrictEqual(options, { action: "none" }); + }); + }); + + describe("flavor", () => { + it("should default to ethereum", () => { + const options = args(versionString, isDocker, []); + + assert.strictEqual(options.action, "start"); + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above. + if (options.action === "start") { + assert.strictEqual(options.flavor, "ethereum"); + } + }); + + it("should accept a flavor of ethereum", () => { + const options = args(versionString, isDocker, ["ethereum"]); + + assert.strictEqual(options.action, "start"); + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above. + if (options.action === "start") { + assert.strictEqual(options.flavor, "ethereum"); + } + }); + + it("should accept a flavor of filecoin", () => { + const options = args(versionString, isDocker, ["filecoin"]); + + assert.strictEqual(options.action, "start"); + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above. + if (options.action === "start") { + assert.strictEqual(options.flavor, "filecoin"); + } + }); + + it("should reject a non-standard flavor", () => { + assert.throws(() => args(versionString, isDocker, ["not-a-flavor"]), { + name: "YError", + message: "Unknown argument: not-a-flavor" + }); + }); + }); + + describe("host and port", () => { + it("should default configuration to 127.0.0.1:8545", () => { + const options = args(versionString, isDocker, []); + + assert.strictEqual(options.action, "start"); + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above. + if (options.action === "start") { + assert.strictEqual(options.server.host, "127.0.0.1"); + assert.strictEqual(options.server.port, 8545); + } + }); + + it("should default configuration to 0.0.0.0:8545 if running within docker", () => { + const options = args(versionString, true, []); + + assert.strictEqual(options.action, "start"); + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above. + if (options.action === "start") { + assert.strictEqual(options.server.host, "0.0.0.0"); + assert.strictEqual(options.server.port, 8545); + } + }); + + it("should parse the provided host configuration", () => { + const options = args(versionString, true, ["--host", "localhost"]); + + assert.strictEqual(options.action, "start"); + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above + if (options.action === "start") { + assert.strictEqual(options.server.host, "localhost"); + assert.strictEqual(options.server.port, 8545); + } + }); + + it("should parse the provided port configuration", () => { + const options = args(versionString, isDocker, ["--port", "1234"]); + + assert.strictEqual(options.action, "start"); + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above + if (options.action === "start") { + assert.strictEqual(options.server.host, "127.0.0.1"); + assert.strictEqual(options.server.port, 1234); + } + }); + + it("should reject invalid host argument", () => { + const invalidHost = ""; + const rawArgs = ["--host", invalidHost]; + + assert.throws( + () => args(versionString, isDocker, rawArgs), + new Error("Cannot leave host blank; please provide a host") + ); + }); + + it("should reject invalid port argument", () => { + const invalidPorts = ["0", "-1", "65536", "absolutely-not-a-port"]; + for (const port of invalidPorts) { + const rawArgs = ["--port", port]; + + assert.throws( + () => args(versionString, isDocker, rawArgs), + new Error(`Port should be >= 0 and < 65536. Received ${port}.`), + `Expected to throw with supplied port of '${port}'` + ); + } + }); + }); + describe("detach", () => { const detachModeArgs = [ "--detach", "--D", @@ -112,5 +236,94 @@ describe("args", () => { }); }); }); + + describe("instances", () => { + it("should fail when no sub-command is specified", () => { + const rawArgs = ["instances"]; + assert.throws(() => args(versionString, false, rawArgs), { + name: "Error", + message: + "No sub-command given. See `ganache instances --help` for more information." + }); + }); + + it("should fail when invalid sub-command is specified", () => { + const rawArgs = ["instances", "invalid-command"]; + assert.throws(() => args(versionString, false, rawArgs), { + name: "YError", + message: "Unknown argument: invalid-command" + }); + }); + + describe("list", () => { + it("should parse the `list` sub command", () => { + const rawArgs = ["instances", "list"]; + const options = args(versionString, isDocker, rawArgs); + assert.strictEqual( + options.action, + "list", + "Expected action to be 'list' when 'list' sub-command is specified" + ); + }); + + it("should not accept options", () => { + const invalidOptions = ["detach", "port", "host", "chain.blockTime"]; + + for (let option of invalidOptions) { + const rawArgs = ["instances", "list", `--${option}`]; + + assert.throws( + () => args(versionString, isDocker, rawArgs), + { + name: "YError", + message: `Unknown argument: ${option}` + }, + `Expected to throw with arguments of '${JSON.stringify(rawArgs)}'` + ); + } + }); + + it("should not accept an additional command", () => { + const rawArgs = ["instances", "list", `additional-command`]; + + assert.throws( + () => args(versionString, isDocker, rawArgs), + { + name: "YError", + message: "Unknown argument: additional-command" + }, + `Expected to throw with arguments of '${JSON.stringify(rawArgs)}'` + ); + }); + }); + + describe("stop", () => { + it("should fail if no instance name is supplied", () => { + const rawArgs = ["instances", "stop"]; + assert.throws(() => args(versionString, isDocker, rawArgs)); + }); + + it("should parse the `stop` sub command", () => { + const rawArgs = ["instances", "stop", "instance-name"]; + const options = args(versionString, isDocker, rawArgs); + + assert.strictEqual( + options.action, + "stop", + "Expected action to be 'list' when 'list' sub-command is specified" + ); + + // We check the value of `options.action` here in order to narrow the + // type of options. The negative case fails in the assert above + if (options.action === "stop") { + assert.strictEqual( + options.name, + "instance-name", + "Instance name should have been the specified argument" + ); + } + }); + }); + }); }); }); diff --git a/src/packages/cli/tests/process-name.test.ts b/src/packages/cli/tests/process-name.test.ts new file mode 100644 index 0000000000..af6048a666 --- /dev/null +++ b/src/packages/cli/tests/process-name.test.ts @@ -0,0 +1,64 @@ +import assert from "assert"; +import createProcessName from "../src/process-name"; + +// generates a "random" number generator function that, when called, returns (in +// order) each value from the provided array, followed by -1 when there are none +// left. +function createRandom(...values: number[]): () => number { + return () => (values.length > 0 ? (values.shift() as number) : -1); +} + +const MAX_RANDOM = 0.9999999999999999; + +// the values passed to createRandom specify the "random" number required to +// pick the specified part. This is calculated by: +// `source.indexOf("term") / source.length` +const wellKnownNames = [ + { + random: createRandom(0, 0, 0), + name: "baked_almond_bar" + }, + { + random: createRandom(MAX_RANDOM, MAX_RANDOM, MAX_RANDOM), + name: "sticky_tiramisu_waffle" + }, + { + random: createRandom( + 0.2727272727272727, + 0.21428571428571427, + 0.4444444444444444 + ), + name: "deepfried_chocolate_ganache" + }, + { + random: createRandom( + 0.18181818181818182, + 0.07142857142857142, + 0.9259259259259259 + ), + name: "creamy_banana_truffle" + } +]; + +// it's not necessarily important that this generates the correct names, but +// it's good to test that it's doing what we expect. +describe("createProcessName", () => { + for (const wellKnownName of wellKnownNames) { + it(`should create the correct instance name: ${wellKnownName.name}`, () => { + const generatedName = createProcessName(wellKnownName.random); + + assert.strictEqual(generatedName, wellKnownName.name); + }); + } + + it("should create a process name, without a mocked RNG", () => { + const generatedName = createProcessName(); + // each part must be at least 3 chars long, and at most 20 chars + const nameRegex = /^([a-z]{3,20}_){2}[a-z]{3,20}$/; + assert.match( + generatedName, + nameRegex, + `Exepected to have generated a reasonable name, got "${generatedName}"` + ); + }); +});