Skip to content

Commit

Permalink
feat: add CLI for pg-test (#81)
Browse files Browse the repository at this point in the history
  • Loading branch information
ForbesLindesay authored May 3, 2020
1 parent 626453e commit f09f53f
Show file tree
Hide file tree
Showing 6 changed files with 329 additions and 3 deletions.
28 changes: 28 additions & 0 deletions docs/pg-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@ If you need to run migrations before your tests run, e.g. to create database tab

Your migrations script will run with the `DATABASE_URL` set to the same value as for your tests.

## CLI

To install as a CLI:

```
npm i -g @databases/pg-test
```

To start a local Postgres database on a free port, and apply any migrations you have configured (see Jest), you can run:

```
pg-test start
```

When you're done with your database, you can dispose of it via:

```
pg-test stop
```

If you have a script (e.g. a node.js server) that you need a Postgres database for, and you're happy for that Postgres database to be disposed of as soon as your script exits, you can do that via:

```
pg-test run -- node my-server.js
```

The `--` is optional, but can be used to clarify where the `pg-test` parameters end and your script begins.

## Circle CI

If the `DATABASE_URL` environment is already set, `pg-test` does nothing. This means you can use CircleCI's native support for running tests with an acompanying database to run your tests. In your `.circleci/config.yml`:
Expand Down
3 changes: 1 addition & 2 deletions packages/mysql-test/src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import ms = require('ms');
import {parse, startChain, param} from 'parameter-reducers';
import {parsedString} from 'parameter-reducers/lib/parameters';
import * as ta from 'type-assertions';
import {getMySqlConfigSync} from '@databases/mysql-config';
import getDatabase, {Options, killDatabase} from '.';
import {execBuffered, spawnBuffered} from 'modern-spawn';

const seconds = <TName extends string>(keys: string[], name: TName) => {
return parsedString(keys, name, (str, key) => {
return param.parsedString(keys, name, (str, key) => {
if (/^\d+$/.test(str)) {
return {valid: true, value: parseInt(str, 10)};
}
Expand Down
9 changes: 8 additions & 1 deletion packages/pg-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
"description": "",
"main": "./lib/index.js",
"types": "./lib/index.d.ts",
"bin": {
"pg-test": "./lib/cli.js"
},
"dependencies": {
"@databases/pg-config": "^0.0.0",
"@databases/with-container": "^0.0.0",
"modern-spawn": "^1.0.0"
"modern-spawn": "^1.0.0",
"ms": "^2.1.2",
"parameter-reducers": "^1.0.1",
"type-assertions": "^1.1.0"
},
"scripts": {},
"repository": "https://github.com/ForbesLindesay/atdatabases/tree/master/packages/pg-test",
Expand All @@ -16,6 +22,7 @@
"access": "public"
},
"devDependencies": {
"@types/ms": "^0.7.31",
"@types/node": "^13.13.4"
},
"bugs": "https://github.com/ForbesLindesay/atdatabases/issues",
Expand Down
62 changes: 62 additions & 0 deletions packages/pg-test/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#! /usr/bin/env node

import * as commands from './commands';
const command = process.argv[2];
const args = process.argv.slice(3);

const hasHelpFlag = args.includes('--help') || args.includes('-h');
if (hasHelpFlag) {
commands.help();
process.exit(0);
}

switch (command) {
case 'start':
if (hasHelpFlag) {
commands.help('start');
} else {
handle(commands.start(args));
}
break;
case 'run':
if (hasHelpFlag) {
commands.help('run');
} else {
handle(commands.run(args));
}
break;
case 'stop':
if (hasHelpFlag) {
commands.help('stop');
} else {
handle(commands.stop(args));
}
break;
case 'help':
commands.help(args[0]);
break;
default:
commands.help();
if (!hasHelpFlag) {
console.error(
`Unexpected command ${command}, expected one of "start" or "help"`,
);
process.exit(1);
}
break;
}

function handle(v: Promise<number>) {
if (!v) {
process.exit(0);
}
v.then(
(value) => {
process.exit(value);
},
(ex) => {
console.error(ex.stack || ex);
process.exit(1);
},
);
}
221 changes: 221 additions & 0 deletions packages/pg-test/src/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import ms = require('ms');
import {parse, startChain, param} from 'parameter-reducers';
import * as ta from 'type-assertions';
import {getPgConfigSync} from '@databases/pg-config';
import getDatabase, {Options, killDatabase} from '.';
import {execBuffered, spawnBuffered} from 'modern-spawn';

const seconds = <TName extends string>(keys: string[], name: TName) => {
return param.parsedString(keys, name, (str, key) => {
if (/^\d+$/.test(str)) {
return {valid: true, value: parseInt(str, 10)};
}
try {
const value = ms(str);
if (value !== undefined) {
return {valid: true, value: Math.round(value / 1000)};
}
} catch (ex) {
// return default error message
}
return {
valid: false,
reason: `Expected ${key} to be a valid number of seconds`,
};
});
};

const params = startChain()
.addParam(param.flag(['-d', '--debug'], 'debug'))
.addParam(param.string(['--image'], 'image'))
.addParam(param.string(['--containerName'], 'containerName'))
.addParam(param.integer(['-p', '--externalPort'], 'externalPort'))
.addParam(seconds(['--connectTimeout'], 'connectTimeoutSeconds'))
.addParam(param.flag(['-r', '--refresh'], 'refreshImage'))

.addParam(param.string(['--user'], 'pgUser'))
.addParam(param.string(['--db'], 'pgDb'));

async function runMigrationsAndAddToEnv(databaseURL: string, debug?: boolean) {
const config = getPgConfigSync();

const DEFAULT_ENV_VAR =
process.env.MYSQL_TEST_ENV_VAR ||
config.connectionStringEnvironmentVariable;
process.env[DEFAULT_ENV_VAR] = databaseURL;

const migrationsScript = process.env.MYSQL_TEST_MIGRATIONS_SCRIPT
? process.env.MYSQL_TEST_MIGRATIONS_SCRIPT.split(' ')
: config.test.migrationsScript;
if (migrationsScript) {
console.warn('Running pg migrations');
if (typeof migrationsScript === 'string') {
await execBuffered(migrationsScript, {
debug: debug || config.test.debug || false,
}).getResult();
} else {
await spawnBuffered(migrationsScript[0], migrationsScript.slice(1), {
debug: debug || config.test.debug || false,
}).getResult();
}
}
}

export async function start(args: string[]) {
const parseResult = parse(params, args);
if (!parseResult.valid) {
console.error(parseResult.reason);
return 1;
}
if (parseResult.rest.length) {
console.error(`Unexpected option ${parseResult.rest[0]}`);
return 1;
}
ta.assert<
ta.Equal<
Pick<
Partial<Options>,
| 'debug'
| 'image'
| 'containerName'
| 'externalPort'
| 'connectTimeoutSeconds'
| 'refreshImage'
| 'pgUser'
| 'pgDb'
> & {environmentVariable?: string},
typeof parseResult.parsed
>
>();
const {databaseURL} = await getDatabase({
...parseResult.parsed,
detached: true,
});

await runMigrationsAndAddToEnv(databaseURL, parseResult.parsed.debug);

console.info(databaseURL);
return 0;
}

export async function run(args: string[]) {
const parseResult = parse(params, args);
if (!parseResult.valid) {
console.error(parseResult.reason);
return 1;
}
const rest =
parseResult.rest[0] === '--' ? parseResult.rest.slice(1) : parseResult.rest;
if (!rest.length) {
console.error(`You must specify a command to run`);
return 1;
}
const {databaseURL, kill} = await getDatabase({
...parseResult.parsed,
detached: true,
});

await runMigrationsAndAddToEnv(databaseURL, parseResult.parsed.debug);

await spawnBuffered(parseResult.rest[0], parseResult.rest.slice(1), {
debug: true,
});

await kill();

return 0;
}

const stopParams = startChain()
.addParam(param.flag(['-d', '--debug'], 'debug'))
.addParam(param.string(['--containerName'], 'containerName'));
export async function stop(args: string[]) {
const parseResult = parse(stopParams, args);
if (!parseResult.valid) {
console.error(parseResult.reason);
return 1;
}
if (parseResult.rest.length) {
console.error(`Unexpected option ${parseResult.rest[0]}`);
return 1;
}
ta.assert<
ta.Equal<
Pick<Partial<Options>, 'debug' | 'containerName'>,
typeof parseResult.parsed
>
>();
await killDatabase({
...parseResult.parsed,
detached: true,
});
return 0;
}

// prettier-ignore
export function help(command?: string) {
switch (command) {
case 'start':
console.info(`usage: pg-test start [-h] ...`);
console.info(``);
console.info(`Start temporary databases for running tests, using docker`);
console.info(``);
console.info(`Optional arguments:`);
console.info(` -d, --debug Print all the output of child commands.`);
console.info(` --image <string> Override the MySQL docker image.`);
console.info(` --containerName <string> Specify a custom name for the container.`);
console.info(` -p, --externalPort <integer> Specify the port to run on.`);
console.info(` --connectTimeout <seconds> How long should we allow for the container`);
console.info(` to start. You can specify a raw number in`);
console.info(` seconds, or a time string like "1 minute"`);
console.info(` -r, --refresh Update the cached docker conatiner`);
console.info(` -user <string> The pg user`);
console.info(` -password <string> The pg password`);
console.info(` -db <string> The pg database`);
console.info(` -h, --help Show this help message and exit.`);
break;
case 'run':
console.info(`usage: pg-test run <options> your-command`);
console.info(``);
console.info(`Run your command with a MySQL database that is disposed of when your command exits`);
console.info(``);
console.info(`Optional arguments:`);
console.info(` -d, --debug Print all the output of child commands.`);
console.info(` --image <string> Override the Postgres docker image.`);
console.info(` --containerName <string> Specify a custom name for the container.`);
console.info(` -p, --externalPort <integer> Specify the port to run on.`);
console.info(` --connectTimeout <seconds> How long should we allow for the container`);
console.info(` to start. You can specify a raw number in`);
console.info(` seconds, or a time string like "1 minute"`);
console.info(` -r, --refresh Update the cached docker conatiner`);
console.info(` -user <string> The postgres user`);
console.info(` -db <string> The postgres database`);
console.info(` -h, --help Show this help message and exit.`);
break;
case 'stop':
console.info(`usage: pg-test stop [-h] ...`);
console.info(``);
console.info(`Stop temporary databases created via pg-test start`);
console.info(``);
console.info(`Optional arguments:`);
console.info(` -d, --debug Print all the output of child commands.`);
console.info(` --containerName <string> Specify a custom name for the container.`);
break;
default:
console.info(`usage: pg-test <command> [-h] ...`);
console.info(``);
console.info(`Start temporary databases for running tests using docker`);
console.info(``);
console.info(`Commands`);
console.info(` start Starts a Postgres database`);
console.info(` run Run a command with a Postgres database that is disposed of at the end`);
console.info(` stop Stops a Postgres database`);
console.info(` help Print documentation for commands`);
console.info(``);
console.info(`Optional arguments:`);
console.info(` -h, --help Show this help message and exit.`);
console.info(``);
console.info(`For detailed help about a specific command, use: pg-test help <command>`);
break;
}
}
9 changes: 9 additions & 0 deletions packages/pg-test/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import startContainer, {
Options as WithContainerOptions,
killOldContainers,
} from '@databases/with-container';
import {getPgConfigSync} from '@databases/pg-config';

Expand Down Expand Up @@ -33,6 +34,14 @@ export interface Options
pgDb: string;
}

export async function killDatabase(options: Partial<Options> = {}) {
await killOldContainers({
debug: DEFAULT_PG_DEBUG,
containerName: DEFAULT_CONTAINER_NAME,
...options,
});
}

export default async function getDatabase(options: Partial<Options> = {}) {
const {pgUser, pgDb, environment, ...rawOptions}: Options = {
debug: DEFAULT_PG_DEBUG,
Expand Down

0 comments on commit f09f53f

Please sign in to comment.