feat(CLI): v2
This commit is contained in:
192
src/cli/tests/cli.integration.spec.ts
Normal file
192
src/cli/tests/cli.integration.spec.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Tests in this file actually run the CLI and attempt to validate its behavior.
|
||||
// Git must be installed on the PATH of the testing machine.
|
||||
|
||||
// tslint:disable:no-expression-statement
|
||||
import test, { ExecutionContext } from 'ava';
|
||||
import del from 'del';
|
||||
import execa from 'execa';
|
||||
import meow from 'meow';
|
||||
import { join } from 'path';
|
||||
|
||||
test('returns version', async t => {
|
||||
const expected = meow('').pkg.version;
|
||||
t.truthy(typeof expected === 'string');
|
||||
const { stdout } = await execa(`./bin/typescript-starter`, ['--version']);
|
||||
t.is(stdout, expected);
|
||||
});
|
||||
|
||||
test('returns help/usage', async t => {
|
||||
const { stdout } = await execa(`./bin/typescript-starter`, ['--help']);
|
||||
t.regex(stdout, /Usage/);
|
||||
});
|
||||
|
||||
/**
|
||||
* NOTE: many of the tests below validate file modification. The filesystem is
|
||||
* not mocked, and these tests make real changes. Proceed with caution.
|
||||
*
|
||||
* TODO: mock the filesystem - https://github.com/avajs/ava/issues/665
|
||||
*
|
||||
* Until the filesystem is mocked, filesystem changes made by these tests should
|
||||
* be contained in the `build` directory for easier clean up.
|
||||
*/
|
||||
|
||||
enum testDirectories {
|
||||
one = 'test-one',
|
||||
two = 'test-two',
|
||||
three = 'test-three',
|
||||
four = 'test-four'
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await del([
|
||||
`./build/${testDirectories.one}`,
|
||||
`./build/${testDirectories.two}`,
|
||||
`./build/${testDirectories.three}`,
|
||||
`./build/${testDirectories.four}`
|
||||
]);
|
||||
});
|
||||
|
||||
test('errors if project name collides with an existing path', async t => {
|
||||
const existingDir = 'build';
|
||||
const error = await t.throws(
|
||||
execa(`./bin/typescript-starter`, [existingDir])
|
||||
);
|
||||
t.regex(error.stderr, /"build" path already exists/);
|
||||
});
|
||||
|
||||
test('errors if project name is not in kebab-case', async t => {
|
||||
const error = await t.throws(
|
||||
execa(`./bin/typescript-starter`, ['name with spaces'])
|
||||
);
|
||||
t.regex(error.stderr, /should be in-kebab-case/);
|
||||
});
|
||||
|
||||
test('integration test 1: parses CLI arguments, handles options properly', async t => {
|
||||
const { stdout } = await execa(
|
||||
`../bin/typescript-starter`,
|
||||
[
|
||||
`${testDirectories.one}`,
|
||||
'-description "example description 1"',
|
||||
'--noinstall'
|
||||
],
|
||||
{
|
||||
cwd: join(process.cwd(), 'build'),
|
||||
env: {
|
||||
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
t.regex(stdout, new RegExp(`Created ${testDirectories.one} 🎉`));
|
||||
// TODO: validate contents of testDirectories.one
|
||||
});
|
||||
|
||||
test('integration test 2: parses CLI arguments, handles options properly', async t => {
|
||||
const { stdout } = await execa(
|
||||
`../bin/typescript-starter`,
|
||||
[
|
||||
`${testDirectories.two}`,
|
||||
'-description "example description 2"',
|
||||
'--yarn',
|
||||
'--node',
|
||||
'--dom',
|
||||
'--noinstall'
|
||||
],
|
||||
{
|
||||
cwd: join(process.cwd(), 'build'),
|
||||
env: {
|
||||
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
t.regex(stdout, new RegExp(`Created ${testDirectories.two} 🎉`));
|
||||
// TODO: validate contents of testDirectories.two
|
||||
});
|
||||
|
||||
const down = '\x1B\x5B\x42';
|
||||
const up = '\x1B\x5B\x41';
|
||||
const enter = '\x0D';
|
||||
const ms = (milliseconds: number) =>
|
||||
new Promise<void>(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function testInteractive(
|
||||
t: ExecutionContext<{}>,
|
||||
projectName: string,
|
||||
entry: ReadonlyArray<string | ReadonlyArray<string>>
|
||||
): Promise<void> {
|
||||
const lastCheck = entry[3] !== undefined;
|
||||
t.plan(lastCheck ? 6 : 5);
|
||||
|
||||
const proc = execa(`../bin/typescript-starter`, ['--noinstall'], {
|
||||
cwd: join(process.cwd(), 'build'),
|
||||
env: {
|
||||
TYPESCRIPT_STARTER_REPO_URL: process.cwd()
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: missing in Node.js type definition's ChildProcess.stdin?
|
||||
// https://nodejs.org/api/process.html#process_process_stdin
|
||||
// proc.stdin.setEncoding('utf8');
|
||||
|
||||
// tslint:disable-next-line:prefer-const no-let
|
||||
let buffer = '';
|
||||
const checkBuffer = (regex: RegExp) => t.regex(buffer, regex);
|
||||
const type = (input: string) => proc.stdin.write(input);
|
||||
const clearBuffer = () => (buffer = '');
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
});
|
||||
|
||||
// wait for first chunk to be emitted
|
||||
await new Promise(resolve => {
|
||||
proc.stdout.once('data', resolve);
|
||||
});
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`typescript-starter.|s*Enter the new package name`));
|
||||
clearBuffer();
|
||||
type(`${projectName}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${projectName}.|s*What are you making?`));
|
||||
clearBuffer();
|
||||
type(`${entry[0][0]}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${entry[0][1]}.|s*Enter the package description`));
|
||||
clearBuffer();
|
||||
type(`${entry[1]}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${entry[1]}.|s*npm or yarn?`));
|
||||
clearBuffer();
|
||||
type(`${entry[2][0]}${enter}`);
|
||||
await ms(200);
|
||||
|
||||
const search = `${entry[2][1]}.|s*global type definitions`;
|
||||
const exp = lastCheck
|
||||
? new RegExp(`${search}`) // should match
|
||||
: new RegExp(`(?!${search})`); // should not match
|
||||
checkBuffer(exp);
|
||||
// tslint:disable-next-line:no-if-statement
|
||||
if (lastCheck) {
|
||||
clearBuffer();
|
||||
type(`${entry[3][0]}${enter}`);
|
||||
await ms(200);
|
||||
checkBuffer(new RegExp(`${entry[3][1]}`));
|
||||
}
|
||||
|
||||
// TODO: validate contents?
|
||||
}
|
||||
|
||||
test('integration test 3: interactive mode, javascript library', async t => {
|
||||
await testInteractive(t, `${testDirectories.three}`, [
|
||||
[`${down}${up}${down}`, `Javascript library`],
|
||||
`integration test 3 description${enter}`,
|
||||
[`${down}${up}${down}${enter}`, `yarn`],
|
||||
[`${down}${down}${down}${enter}`, `Both Node.js and DOM`]
|
||||
]);
|
||||
});
|
||||
|
||||
test('integration test 4: interactive mode, node.js application', async t => {
|
||||
await testInteractive(t, `${testDirectories.four}`, [
|
||||
[`${down}${up}`, `Node.js application`],
|
||||
`integration test 4 description${enter}`,
|
||||
[`${down}${up}${enter}`, `npm`]
|
||||
]);
|
||||
});
|
||||
133
src/cli/tests/cli.unit.spec.ts
Normal file
133
src/cli/tests/cli.unit.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// tslint:disable:no-expression-statement
|
||||
import test from 'ava';
|
||||
import { ExecaStatic } from 'execa';
|
||||
import meow from 'meow';
|
||||
import nock from 'nock';
|
||||
import { checkArgs } from '../args';
|
||||
import { getIntro, Runner } from '../primitives';
|
||||
import {
|
||||
cloneRepo,
|
||||
getGithubUsername,
|
||||
getUserInfo,
|
||||
initialCommit,
|
||||
install
|
||||
} from '../tasks';
|
||||
|
||||
test('errors if outdated', async t => {
|
||||
nock.disableNetConnect();
|
||||
nock('https://registry.npmjs.org:443')
|
||||
.get('/typescript-starter')
|
||||
.reply(200, {
|
||||
'dist-tags': { latest: '9000.0.1' },
|
||||
name: 'typescript-starter',
|
||||
versions: {
|
||||
'9000.0.1': {
|
||||
version: '9000.0.1'
|
||||
}
|
||||
}
|
||||
});
|
||||
const error = await t.throws(checkArgs);
|
||||
t.regex(error.message, /is outdated/);
|
||||
});
|
||||
|
||||
test(`doesn't error if not outdated`, async t => {
|
||||
const currentVersion = meow('').pkg.version;
|
||||
t.truthy(typeof currentVersion === 'string');
|
||||
nock.disableNetConnect();
|
||||
nock('https://registry.npmjs.org:443')
|
||||
.get('/typescript-starter')
|
||||
.reply(200, {
|
||||
'dist-tags': { latest: currentVersion },
|
||||
name: 'typescript-starter',
|
||||
versions: {
|
||||
[currentVersion]: {
|
||||
version: currentVersion
|
||||
}
|
||||
}
|
||||
});
|
||||
await t.notThrows(checkArgs);
|
||||
});
|
||||
|
||||
test(`errors if update-notifier fails`, async t => {
|
||||
nock.disableNetConnect();
|
||||
nock('https://registry.npmjs.org:443')
|
||||
.get('/typescript-starter')
|
||||
.reply(404, {});
|
||||
const error = await t.throws(checkArgs);
|
||||
t.regex(error.message, /doesn\'t exist/);
|
||||
});
|
||||
|
||||
test('ascii art shows if stdout has 85+ columns', async t => {
|
||||
const jumbo = getIntro(100);
|
||||
const snippet = `| __| | | | '_ \\ / _ \\/ __|/ __| '__| | '_ \\|`;
|
||||
t.regex(jumbo, new RegExp(snippet));
|
||||
});
|
||||
|
||||
const mockErr = (code?: string | number) =>
|
||||
((() => {
|
||||
const err = new Error();
|
||||
// tslint:disable-next-line:no-object-mutation
|
||||
(err as any).code = code;
|
||||
throw err;
|
||||
}) as any) as ExecaStatic;
|
||||
|
||||
test('cloneRepo: errors when Git is not installed on PATH', async t => {
|
||||
const error = await t.throws(cloneRepo(mockErr('ENOENT'))('fail'));
|
||||
t.regex(error.message, /Git is not installed on your PATH/);
|
||||
});
|
||||
|
||||
test('cloneRepo: throws when clone fails', async t => {
|
||||
const error = await t.throws(cloneRepo(mockErr(128))('fail'));
|
||||
t.regex(error.message, /Git clone failed./);
|
||||
});
|
||||
|
||||
test('cloneRepo: throws when rev-parse fails', async t => {
|
||||
// tslint:disable-next-line:prefer-const no-let
|
||||
let calls = 0;
|
||||
const mock = () => {
|
||||
calls++;
|
||||
return calls === 1 ? {} : (mockErr(128) as any)();
|
||||
};
|
||||
const error = await t.throws(cloneRepo((mock as any) as ExecaStatic)('fail'));
|
||||
t.regex(error.message, /Git rev-parse failed./);
|
||||
});
|
||||
|
||||
test('getGithubUsername: returns found users', async t => {
|
||||
const mockFetcher = async (email: string) => email.split('@')[0];
|
||||
const username: string = await getGithubUsername(mockFetcher)(
|
||||
'bitjson@github.com'
|
||||
);
|
||||
t.is(username, 'bitjson');
|
||||
});
|
||||
|
||||
test('getGithubUsername: returns placeholder if not found', async t => {
|
||||
const mockFetcher = async () => {
|
||||
throw new Error();
|
||||
};
|
||||
const username: string = await getGithubUsername(mockFetcher)(
|
||||
'bitjson@github.com'
|
||||
);
|
||||
t.is(username, 'YOUR_USER_NAME');
|
||||
});
|
||||
|
||||
test('getUserInfo: throws generated errors', async t => {
|
||||
const error = await t.throws(getUserInfo(mockErr(1))());
|
||||
t.is(error.code, 1);
|
||||
});
|
||||
|
||||
test('initialCommit: throws generated errors', async t => {
|
||||
const error = await t.throws(initialCommit(mockErr(1))('deadbeef', 'fail'));
|
||||
t.is(error.code, 1);
|
||||
});
|
||||
|
||||
test('install: uses the correct runner', async t => {
|
||||
const mock = (((runner: Runner) => {
|
||||
runner === Runner.Yarn ? t.pass() : t.fail();
|
||||
}) as any) as ExecaStatic;
|
||||
await install(mock)(true, Runner.Yarn, 'pass');
|
||||
});
|
||||
|
||||
test('install: throws pretty error on failure', async t => {
|
||||
const error = await t.throws(install(mockErr())(true, Runner.Npm, 'fail'));
|
||||
t.is(error.message, "Installation failed. You'll need to install manually.");
|
||||
});
|
||||
Reference in New Issue
Block a user