[cli] Add full stdio mockability for unit tests (#8052)

This PR is a follow-up to #8039, which provides an intuitive syntax for writing unit tests for interactive CLI commands.

The heart of this is the new `await expect(stream).toOutput(test)` custom Jest matcher, which is intended for use with the mock Client `stdout` and `stderr` stream properties. The `test` is a string that will wait for the stream to output via "data" events until a match is found, or it will timeout (after 3 seconds by default). The timeout error has nice Jest-style formatting so that you can easily identify what was output:

<img width="553" alt="Screen Shot 2022-06-29 at 10 33 06 PM" src="https://user-images.githubusercontent.com/71256/176600324-cb1ebecb-e891-42d9-bdc9-4864d3594a8c.png">

Below is an example of a unit test that was added for an interactive `vc login` session:

```typescript
it('should allow login via email', async () => {
  const user = useUser();

  const exitCodePromise = login(client);

  // Wait for login interactive prompt
  await expect(client.stderr).toOutput(`> Log in to Vercel`);

  // Move down to "Email" option
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\r'); // Return key

  // Wait for email input prompt
  await expect(client.stderr).toOutput('> Enter your email address:');

  // Write user email address into prompt
  client.stdin.write(`${user.email}\n`);

  // Wait for login success message
  await expect(client.stderr).toOutput(
    `Success! Email authentication complete for ${user.email}`
  );

  // Assert that the `login()` command returned 0 exit code
  await expect(exitCodePromise).resolves.toEqual(0);
});
```

**Note:**  as a consequence of this PR, prompts are now written to stderr instead of stdout, so this change _may_ be considered a breaking change, in which case we should tag it for major release.
This commit is contained in:
Nathan Rajlich
2022-06-30 13:17:22 -07:00
committed by GitHub
parent 547e88228e
commit 695bfbdd60
41 changed files with 559 additions and 330 deletions

View File

@@ -127,7 +127,7 @@ export default async function ({ creditCards, clear = false, contextName }) {
} }
console.log(''); // New line console.log(''); // New line
const stopSpinner = wait('Saving card'); const stopSpinner = wait(process.stderr, 'Saving card');
try { try {
const res = await creditCards.add({ const res = await creditCards.add({

View File

@@ -174,7 +174,7 @@ export default async client => {
)} ${chalk.gray(`[${elapsed}]`)}`; )} ${chalk.gray(`[${elapsed}]`)}`;
const choices = buildInquirerChoices(cards); const choices = buildInquirerChoices(cards);
cardId = await listInput({ cardId = await listInput(client, {
message, message,
choices, choices,
separator: true, separator: true,
@@ -251,7 +251,7 @@ export default async client => {
)} under ${chalk.bold(contextName)} ${chalk.gray(`[${elapsed}]`)}`; )} under ${chalk.bold(contextName)} ${chalk.gray(`[${elapsed}]`)}`;
const choices = buildInquirerChoices(cards); const choices = buildInquirerChoices(cards);
cardId = await listInput({ cardId = await listInput(client, {
message, message,
choices, choices,
separator: true, separator: true,

View File

@@ -14,7 +14,6 @@ import logo from '../../util/output/logo';
import getArgs from '../../util/get-args'; import getArgs from '../../util/get-args';
import Client from '../../util/client'; import Client from '../../util/client';
import { getPkgName } from '../../util/pkg-name'; import { getPkgName } from '../../util/pkg-name';
import { Output } from '../../util/output';
import { Deployment, PaginationOptions } from '../../types'; import { Deployment, PaginationOptions } from '../../types';
import { normalizeURL } from '../../util/bisect/normalize-url'; import { normalizeURL } from '../../util/bisect/normalize-url';
@@ -86,10 +85,10 @@ export default async function main(client: Client): Promise<number> {
let bad = let bad =
argv['--bad'] || argv['--bad'] ||
(await prompt(output, `Specify a URL where the bug occurs:`)); (await prompt(client, `Specify a URL where the bug occurs:`));
let good = let good =
argv['--good'] || argv['--good'] ||
(await prompt(output, `Specify a URL where the bug does not occur:`)); (await prompt(client, `Specify a URL where the bug does not occur:`));
let subpath = argv['--path'] || ''; let subpath = argv['--path'] || '';
let run = argv['--run'] || ''; let run = argv['--run'] || '';
const openEnabled = argv['--open'] || false; const openEnabled = argv['--open'] || false;
@@ -143,7 +142,7 @@ export default async function main(client: Client): Promise<number> {
if (!subpath) { if (!subpath) {
subpath = await prompt( subpath = await prompt(
output, client,
`Specify the URL subpath where the bug occurs:` `Specify the URL subpath where the bug occurs:`
); );
} }
@@ -391,10 +390,10 @@ function getCommit(deployment: DeploymentV6) {
return { sha, message }; return { sha, message };
} }
async function prompt(output: Output, message: string): Promise<string> { async function prompt(client: Client, message: string): Promise<string> {
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const { val } = await inquirer.prompt({ const { val } = await client.prompt({
type: 'input', type: 'input',
name: 'val', name: 'val',
message, message,
@@ -402,7 +401,7 @@ async function prompt(output: Output, message: string): Promise<string> {
if (val) { if (val) {
return val; return val;
} else { } else {
output.error('A value must be specified'); client.output.error('A value must be specified');
} }
} }
} }

View File

@@ -160,9 +160,9 @@ export default async (client: Client) => {
} }
} }
const { log, debug, error, prettyError, isTTY } = output; const { log, debug, error, prettyError } = output;
const quiet = !isTTY; const quiet = !client.stdout.isTTY;
// check paths // check paths
const pathValidation = await validatePaths(client, paths); const pathValidation = await validatePaths(client, paths);

View File

@@ -46,7 +46,11 @@ export default async function init(
const exampleList = examples.filter(x => x.visible).map(x => x.name); const exampleList = examples.filter(x => x.visible).map(x => x.name);
if (!name) { if (!name) {
const chosen = await chooseFromDropdown('Select example:', exampleList); const chosen = await chooseFromDropdown(
client,
'Select example:',
exampleList
);
if (!chosen) { if (!chosen) {
output.log('Aborted'); output.log('Aborted');
@@ -90,14 +94,18 @@ async function fetchExampleList(client: Client) {
/** /**
* Prompt user for choosing which example to init * Prompt user for choosing which example to init
*/ */
async function chooseFromDropdown(message: string, exampleList: string[]) { async function chooseFromDropdown(
client: Client,
message: string,
exampleList: string[]
) {
const choices = exampleList.map(name => ({ const choices = exampleList.map(name => ({
name, name,
value: name, value: name,
short: name, short: name,
})); }));
return listInput({ return listInput(client, {
message, message,
choices, choices,
}); });

View File

@@ -83,7 +83,7 @@ export default async function main(client: Client, desiredSlug?: string) {
]; ];
output.stopSpinner(); output.stopSpinner();
desiredSlug = await listInput({ desiredSlug = await listInput(client, {
message: 'Switch to:', message: 'Switch to:',
choices, choices,
eraseFinalAnswer: true, eraseFinalAnswer: true,

View File

@@ -54,12 +54,12 @@ export default async (client: Client): Promise<number> => {
throw err; throw err;
} }
if (output.isTTY) { if (client.stdout.isTTY) {
output.log(contextName); output.log(contextName);
} else { } else {
// If stdout is not a TTY, then only print the username // If stdout is not a TTY, then only print the username
// to support piping the output to another file / exe // to support piping the output to another file / exe
output.print(`${contextName}\n`, { w: process.stdout }); client.stdout.write(`${contextName}\n`);
} }
return 0; return 0;

View File

@@ -23,7 +23,7 @@ import * as Sentry from '@sentry/node';
import hp from './util/humanize-path'; import hp from './util/humanize-path';
import commands from './commands'; import commands from './commands';
import pkg from './util/pkg'; import pkg from './util/pkg';
import createOutput from './util/output'; import { Output } from './util/output';
import cmd from './util/output/cmd'; import cmd from './util/output/cmd';
import info from './util/output/info'; import info from './util/output/info';
import error from './util/output/error'; import error from './util/output/error';
@@ -109,7 +109,7 @@ const main = async () => {
} }
const isDebugging = argv['--debug']; const isDebugging = argv['--debug'];
const output = createOutput({ debug: isDebugging }); const output = new Output(process.stderr, { debug: isDebugging });
debug = output.debug; debug = output.debug;
@@ -389,6 +389,7 @@ const main = async () => {
apiUrl, apiUrl,
stdin: process.stdin, stdin: process.stdin,
stdout: process.stdout, stdout: process.stdout,
stderr: output.stream,
output, output,
config, config,
authConfig, authConfig,
@@ -798,7 +799,5 @@ process.on('uncaughtException', handleUnexpected);
main() main()
.then(exitCode => { .then(exitCode => {
process.exitCode = exitCode; process.exitCode = exitCode;
// @ts-ignore - "nowExit" is a non-standard event name
process.emit('nowExit');
}) })
.catch(handleUnexpected); .catch(handleUnexpected);

View File

@@ -1,3 +1,5 @@
import type { Readable, Writable } from 'stream';
export type ProjectSettings = import('@vercel/build-utils').ProjectSettings; export type ProjectSettings = import('@vercel/build-utils').ProjectSettings;
export type Primitive = export type Primitive =
@@ -442,3 +444,19 @@ export interface BuildOutput {
layers?: string[]; layers?: string[];
} | null; } | null;
} }
export interface ReadableTTY extends Readable {
isTTY?: boolean;
isRaw?: boolean;
setRawMode?: (mode: boolean) => void;
}
export interface WritableTTY extends Writable {
isTTY?: boolean;
}
export interface Stdio {
stdin: ReadableTTY;
stdout: WritableTTY;
stderr: WritableTTY;
}

View File

@@ -1,3 +1,5 @@
import { bold } from 'chalk';
import inquirer from 'inquirer';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { URLSearchParams } from 'url'; import { URLSearchParams } from 'url';
import { parse as parseUrl } from 'url'; import { parse as parseUrl } from 'url';
@@ -11,10 +13,16 @@ import printIndications from './print-indications';
import reauthenticate from './login/reauthenticate'; import reauthenticate from './login/reauthenticate';
import { SAMLError } from './login/types'; import { SAMLError } from './login/types';
import { writeToAuthConfigFile } from './config/files'; import { writeToAuthConfigFile } from './config/files';
import { AuthConfig, GlobalConfig, JSONObject } from '../types'; import type {
AuthConfig,
GlobalConfig,
JSONObject,
Stdio,
ReadableTTY,
WritableTTY,
} from '../types';
import { sharedPromise } from './promise'; import { sharedPromise } from './promise';
import { APIError } from './errors-ts'; import { APIError } from './errors-ts';
import { bold } from 'chalk';
const isSAMLError = (v: any): v is SAMLError => { const isSAMLError = (v: any): v is SAMLError => {
return v && v.saml; return v && v.saml;
@@ -28,12 +36,10 @@ export interface FetchOptions extends Omit<RequestInit, 'body'> {
accountId?: string; accountId?: string;
} }
export interface ClientOptions { export interface ClientOptions extends Stdio {
argv: string[]; argv: string[];
apiUrl: string; apiUrl: string;
authConfig: AuthConfig; authConfig: AuthConfig;
stdin: NodeJS.ReadStream;
stdout: NodeJS.WriteStream;
output: Output; output: Output;
config: GlobalConfig; config: GlobalConfig;
localConfig?: VercelConfig; localConfig?: VercelConfig;
@@ -43,15 +49,17 @@ export const isJSONObject = (v: any): v is JSONObject => {
return v && typeof v == 'object' && v.constructor === Object; return v && typeof v == 'object' && v.constructor === Object;
}; };
export default class Client extends EventEmitter { export default class Client extends EventEmitter implements Stdio {
argv: string[]; argv: string[];
apiUrl: string; apiUrl: string;
authConfig: AuthConfig; authConfig: AuthConfig;
stdin: NodeJS.ReadStream; stdin: ReadableTTY;
stdout: NodeJS.WriteStream; stdout: WritableTTY;
stderr: WritableTTY;
output: Output; output: Output;
config: GlobalConfig; config: GlobalConfig;
localConfig?: VercelConfig; localConfig?: VercelConfig;
prompt!: inquirer.PromptModule;
private requestIdCounter: number; private requestIdCounter: number;
constructor(opts: ClientOptions) { constructor(opts: ClientOptions) {
@@ -61,10 +69,12 @@ export default class Client extends EventEmitter {
this.authConfig = opts.authConfig; this.authConfig = opts.authConfig;
this.stdin = opts.stdin; this.stdin = opts.stdin;
this.stdout = opts.stdout; this.stdout = opts.stdout;
this.stderr = opts.stderr;
this.output = opts.output; this.output = opts.output;
this.config = opts.config; this.config = opts.config;
this.localConfig = opts.localConfig; this.localConfig = opts.localConfig;
this.requestIdCounter = 1; this.requestIdCounter = 1;
this._createPromptModule();
} }
retry<T>(fn: RetryFunction<T>, { retries = 3, maxTimeout = Infinity } = {}) { retry<T>(fn: RetryFunction<T>, { retries = 3, maxTimeout = Infinity } = {}) {
@@ -130,7 +140,7 @@ export default class Client extends EventEmitter {
return this.retry(async bail => { return this.retry(async bail => {
const res = await this._fetch(url, opts); const res = await this._fetch(url, opts);
printIndications(res); printIndications(this, res);
if (!res.ok) { if (!res.ok) {
const error = await responseError(res); const error = await responseError(res);
@@ -186,4 +196,11 @@ export default class Client extends EventEmitter {
_onRetry = (error: Error) => { _onRetry = (error: Error) => {
this.output.debug(`Retrying: ${error}\n${error.stack}`); this.output.debug(`Retrying: ${error}\n${error.stack}`);
}; };
_createPromptModule() {
this.prompt = inquirer.createPromptModule({
input: this.stdin as NodeJS.ReadStream,
output: this.stderr as NodeJS.WriteStream,
});
}
} }

View File

@@ -1,5 +1,5 @@
import { resolve } from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { resolve } from 'path';
import { getVercelIgnore } from '@vercel/client'; import { getVercelIgnore } from '@vercel/client';
import uniqueStrings from './unique-strings'; import uniqueStrings from './unique-strings';
import { Output } from './output/create-output'; import { Output } from './output/create-output';

View File

@@ -530,7 +530,7 @@ export default class Now extends EventEmitter {
`${opts.method || 'GET'} ${this._apiUrl}${_url} ${opts.body || ''}`, `${opts.method || 'GET'} ${this._apiUrl}${_url} ${opts.body || ''}`,
fetch(`${this._apiUrl}${_url}`, { ...opts, body }) fetch(`${this._apiUrl}${_url}`, { ...opts, body })
); );
printIndications(res); printIndications(this._client, res);
return res; return res;
} }

View File

@@ -1,4 +1,3 @@
import inquirer from 'inquirer';
import Client from '../client'; import Client from '../client';
export default async function confirm( export default async function confirm(
@@ -8,12 +7,7 @@ export default async function confirm(
): Promise<boolean> { ): Promise<boolean> {
require('./patch-inquirer'); require('./patch-inquirer');
const prompt = inquirer.createPromptModule({ const answers = await client.prompt({
input: client.stdin,
output: client.stdout,
});
const answers = await prompt({
type: 'confirm', type: 'confirm',
name: 'value', name: 'value',
message, message,

View File

@@ -1,5 +1,4 @@
import Client from '../client'; import Client from '../client';
import inquirer from 'inquirer';
import confirm from './confirm'; import confirm from './confirm';
import getProjectByIdOrName from '../projects/get-project-by-id-or-name'; import getProjectByIdOrName from '../projects/get-project-by-id-or-name';
import chalk from 'chalk'; import chalk from 'chalk';
@@ -79,11 +78,7 @@ export default async function inputProject(
let project: Project | ProjectNotFound | null = null; let project: Project | ProjectNotFound | null = null;
while (!project || project instanceof ProjectNotFound) { while (!project || project instanceof ProjectNotFound) {
const prompt = inquirer.createPromptModule({ const answers = await client.prompt({
input: client.stdin,
output: client.stdout,
});
const answers = await prompt({
type: 'input', type: 'input',
name: 'existingProjectName', name: 'existingProjectName',
message: `Whats the name of your existing project?`, message: `Whats the name of your existing project?`,
@@ -114,7 +109,7 @@ export default async function inputProject(
let newProjectName: string | null = null; let newProjectName: string | null = null;
while (!newProjectName) { while (!newProjectName) {
const answers = await inquirer.prompt({ const answers = await client.prompt({
type: 'input', type: 'input',
name: 'newProjectName', name: 'newProjectName',
message: `Whats your projects name?`, message: `Whats your projects name?`,

View File

@@ -1,6 +1,5 @@
import path from 'path'; import path from 'path';
import chalk from 'chalk'; import chalk from 'chalk';
import inquirer from 'inquirer';
import { validateRootDirectory } from '../validate-paths'; import { validateRootDirectory } from '../validate-paths';
import Client from '../client'; import Client from '../client';
@@ -15,11 +14,7 @@ export async function inputRootDirectory(
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const prompt = inquirer.createPromptModule({ const { rootDirectory } = await client.prompt({
input: client.stdin,
output: client.stdout,
});
const { rootDirectory } = await prompt({
type: 'input', type: 'input',
name: 'rootDirectory', name: 'rootDirectory',
message: `In which directory is your code located?`, message: `In which directory is your code located?`,

View File

@@ -1,5 +1,6 @@
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import Client from '../client';
import eraseLines from '../output/erase-lines'; import eraseLines from '../output/erase-lines';
interface ListEntry { interface ListEntry {
@@ -35,7 +36,9 @@ function getLength(input: string): number {
return biggestLength; return biggestLength;
} }
export default async function list({ export default async function list(
client: Client,
{
message = 'the question', message = 'the question',
// eslint-disable-line no-unused-vars // eslint-disable-line no-unused-vars
choices: _choices = [ choices: _choices = [
@@ -49,7 +52,8 @@ export default async function list({
separator = false, // Puts a blank separator between each choice separator = false, // Puts a blank separator between each choice
abort = 'end', // Whether the `abort` option will be at the `start` or the `end`, abort = 'end', // Whether the `abort` option will be at the `start` or the `end`,
eraseFinalAnswer = false, // If true, the line with the final answer that inquirer prints will be erased before returning eraseFinalAnswer = false, // If true, the line with the final answer that inquirer prints will be erased before returning
}: ListOptions): Promise<string> { }: ListOptions
): Promise<string> {
require('./patch-inquirer-legacy'); require('./patch-inquirer-legacy');
let biggestLength = 0; let biggestLength = 0;
@@ -106,7 +110,7 @@ export default async function list({
choices.push(abortSeparator, _abort); choices.push(abortSeparator, _abort);
} }
const answer = await inquirer.prompt({ const answer = await client.prompt({
name: 'value', name: 'value',
type: 'list', type: 'list',
default: selected, default: selected,

View File

@@ -1,12 +1,13 @@
import chalk from 'chalk'; import chalk from 'chalk';
import type { ReadableTTY, WritableTTY } from '../../types';
type Options = { type Options = {
abortSequences?: Set<string>; abortSequences?: Set<string>;
defaultValue?: boolean; defaultValue?: boolean;
noChar?: string; noChar?: string;
resolveChars?: Set<string>; resolveChars?: Set<string>;
stdin: NodeJS.ReadStream; stdin: ReadableTTY;
stdout: NodeJS.WriteStream; stdout: WritableTTY;
trailing?: string; trailing?: string;
yesChar?: string; yesChar?: string;
}; };

View File

@@ -1,5 +1,7 @@
import type { ReadableTTY } from '../../types';
export default async function readStandardInput( export default async function readStandardInput(
stdin: NodeJS.ReadStream stdin: ReadableTTY
): Promise<string> { ): Promise<string> {
return new Promise<string>(resolve => { return new Promise<string>(resolve => {
setTimeout(() => resolve(''), 500); setTimeout(() => resolve(''), 500);

View File

@@ -56,7 +56,7 @@ export default async function setupAndLink(
return { status: 'error', exitCode: 1, reason: 'PATH_IS_FILE' }; return { status: 'error', exitCode: 1, reason: 'PATH_IS_FILE' };
} }
const link = await getLinkedProject(client, path); const link = await getLinkedProject(client, path);
const isTTY = process.stdout.isTTY; const isTTY = client.stdin.isTTY;
const quiet = !isTTY; const quiet = !isTTY;
let rootDirectory: string | null = null; let rootDirectory: string | null = null;
let sourceFilesOutsideRootDirectory = true; let sourceFilesOutsideRootDirectory = true;

View File

@@ -176,7 +176,7 @@ async function getVerificationTokenOutOfBand(client: Client, url: URL) {
output.log( output.log(
`After login is complete, enter the verification code printed in your browser.` `After login is complete, enter the verification code printed in your browser.`
); );
const verificationToken = await readInput('Verification code:'); const verificationToken = await readInput(client, 'Verification code:');
output.print(eraseLines(6)); output.print(eraseLines(6));
// If the pasted token begins with "saml_", then the `ssoUserId` was returned. // If the pasted token begins with "saml_", then the `ssoUserId` was returned.

View File

@@ -1,4 +1,3 @@
import inquirer from 'inquirer';
import Client from '../client'; import Client from '../client';
import error from '../output/error'; import error from '../output/error';
import listInput from '../input/list'; import listInput from '../input/list';
@@ -32,7 +31,7 @@ export default async function prompt(
choices.pop(); choices.pop();
} }
const choice = await listInput({ const choice = await listInput(client, {
message: 'Log in to Vercel', message: 'Log in to Vercel',
choices, choices,
}); });
@@ -44,22 +43,26 @@ export default async function prompt(
} else if (choice === 'bitbucket') { } else if (choice === 'bitbucket') {
result = await doBitbucketLogin(client, outOfBand, ssoUserId); result = await doBitbucketLogin(client, outOfBand, ssoUserId);
} else if (choice === 'email') { } else if (choice === 'email') {
const email = await readInput('Enter your email address:'); const email = await readInput(client, 'Enter your email address:');
result = await doEmailLogin(client, email, ssoUserId); result = await doEmailLogin(client, email, ssoUserId);
} else if (choice === 'saml') { } else if (choice === 'saml') {
const slug = error?.teamId || (await readInput('Enter your Team slug:')); const slug =
error?.teamId || (await readInput(client, 'Enter your Team slug:'));
result = await doSamlLogin(client, slug, outOfBand, ssoUserId); result = await doSamlLogin(client, slug, outOfBand, ssoUserId);
} }
return result; return result;
} }
export async function readInput(message: string): Promise<string> { export async function readInput(
client: Client,
message: string
): Promise<string> {
let input; let input;
while (!input) { while (!input) {
try { try {
const { val } = await inquirer.prompt({ const { val } = await client.prompt({
type: 'input', type: 'input',
name: 'val', name: 'val',
message, message,

View File

@@ -1,41 +1,39 @@
import chalk from 'chalk'; import chalk from 'chalk';
import renderLink from './link'; import renderLink from './link';
import wait, { StopSpinner } from './wait'; import wait, { StopSpinner } from './wait';
import { Writable } from 'stream'; import type { WritableTTY } from '../../types';
export interface OutputOptions { export interface OutputOptions {
debug?: boolean; debug?: boolean;
} }
export interface PrintOptions { export interface LogOptions {
w?: Writable;
}
export interface LogOptions extends PrintOptions {
color?: typeof chalk; color?: typeof chalk;
} }
export class Output { export class Output {
stream: WritableTTY;
debugEnabled: boolean; debugEnabled: boolean;
private spinnerMessage: string; private spinnerMessage: string;
private _spinner: StopSpinner | null; private _spinner: StopSpinner | null;
isTTY: boolean;
constructor({ debug: debugEnabled = false }: OutputOptions = {}) { constructor(
stream: WritableTTY,
{ debug: debugEnabled = false }: OutputOptions = {}
) {
this.stream = stream;
this.debugEnabled = debugEnabled; this.debugEnabled = debugEnabled;
this.spinnerMessage = ''; this.spinnerMessage = '';
this._spinner = null; this._spinner = null;
this.isTTY = process.stdout.isTTY || false;
} }
isDebugEnabled = () => { isDebugEnabled = () => {
return this.debugEnabled; return this.debugEnabled;
}; };
print = (str: string, { w }: PrintOptions = { w: process.stderr }) => { print = (str: string) => {
this.stopSpinner(); this.stopSpinner();
const stream: Writable = w || process.stderr; this.stream.write(str);
stream.write(str);
}; };
log = (str: string, color = chalk.grey) => { log = (str: string, color = chalk.grey) => {
@@ -111,11 +109,17 @@ export class Output {
this.debug(`Spinner invoked (${message}) with a ${delay}ms delay`); this.debug(`Spinner invoked (${message}) with a ${delay}ms delay`);
return; return;
} }
if (this.isTTY) { if (this.stream.isTTY) {
if (this._spinner) { if (this._spinner) {
this._spinner.text = message; this._spinner.text = message;
} else { } else {
this._spinner = wait(message, delay); this._spinner = wait(
{
text: message,
stream: this.stream,
},
delay
);
} }
} else { } else {
this.print(`${message}\n`); this.print(`${message}\n`);
@@ -157,7 +161,3 @@ export class Output {
return promise; return promise;
}; };
} }
export default function createOutput(opts?: OutputOptions) {
return new Output(opts);
}

View File

@@ -1,2 +1,2 @@
export { default, Output } from './create-output'; export { Output } from './create-output';
export { StopSpinner } from './wait'; export { StopSpinner } from './wait';

View File

@@ -8,14 +8,19 @@ export interface StopSpinner {
} }
export default function wait( export default function wait(
msg: string, opts: ora.Options,
delay: number = 300, delay: number = 300
_ora = ora
): StopSpinner { ): StopSpinner {
let spinner: ReturnType<typeof _ora> | null = null; let text = opts.text;
let spinner: ora.Ora | null = null;
if (typeof text !== 'string') {
throw new Error(`"text" is required for Ora spinner`);
}
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
spinner = _ora(chalk.gray(msg)); spinner = ora(opts);
spinner.text = chalk.gray(text);
spinner.color = 'gray'; spinner.color = 'gray';
spinner.start(); spinner.start();
}, delay); }, delay);
@@ -29,23 +34,21 @@ export default function wait(
} }
}; };
stop.text = msg; stop.text = text;
// Allow `text` property to update the text while the spinner is in action // Allow `text` property to update the text while the spinner is in action
Object.defineProperty(stop, 'text', { Object.defineProperty(stop, 'text', {
get() { get() {
return msg; return text;
}, },
set(v: string) { set(v: string) {
msg = v; text = v;
if (spinner) { if (spinner) {
spinner.text = chalk.gray(v); spinner.text = chalk.gray(v);
} }
}, },
}); });
// @ts-ignore
process.once('nowExit', stop);
return stop; return stop;
} }

View File

@@ -1,11 +1,10 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { Response } from 'node-fetch'; import { Response } from 'node-fetch';
import { emoji, EmojiLabel, prependEmoji } from './emoji'; import Client from './client';
import createOutput from './output';
import linkStyle from './output/link'; import linkStyle from './output/link';
import { emoji, EmojiLabel, prependEmoji } from './emoji';
export default function printIndications(res: Response) { export default function printIndications(client: Client, res: Response) {
const _output = createOutput();
const indications = new Set(['warning', 'notice', 'tip']); const indications = new Set(['warning', 'notice', 'tip']);
const regex = /^x-(?:vercel|now)-(warning|notice|tip)-(.*)$/; const regex = /^x-(?:vercel|now)-(warning|notice|tip)-(.*)$/;
@@ -25,7 +24,7 @@ export default function printIndications(res: Response) {
chalk.dim(`${action || 'Learn More'}: ${linkStyle(link)}`) + chalk.dim(`${action || 'Learn More'}: ${linkStyle(link)}`) +
newline; newline;
} }
_output.print(message + finalLink); client.output.print(message + finalLink);
} }
} }
} }

View File

@@ -1958,7 +1958,7 @@ test('ensure we render a prompt when deploying home directory', async t => {
t.is(exitCode, 0); t.is(exitCode, 0);
t.true( t.true(
stdout.includes( stderr.includes(
'You are deploying your home directory. Do you want to continue? [y/N]' 'You are deploying your home directory. Do you want to continue? [y/N]'
) )
); );

View File

@@ -1,3 +1,6 @@
// Register Jest matcher extensions for CLI unit tests
import './matchers';
import chalk from 'chalk'; import chalk from 'chalk';
import { PassThrough } from 'stream'; import { PassThrough } from 'stream';
import { createServer, Server } from 'http'; import { createServer, Server } from 'http';
@@ -12,27 +15,43 @@ chalk.level = 0;
export type Scenario = Router; export type Scenario = Router;
class MockStream extends PassThrough {
isTTY: boolean;
constructor() {
super();
this.isTTY = true;
}
// These is for the `ora` module
clearLine() {}
cursorTo() {}
}
export class MockClient extends Client { export class MockClient extends Client {
mockServer?: Server; stdin!: MockStream;
mockOutput: jest.Mock<void, Parameters<Output['print']>>; stdout!: MockStream;
private app: Express; stderr!: MockStream;
scenario: Scenario; scenario: Scenario;
mockServer?: Server;
private app: Express;
constructor() { constructor() {
super({ super({
argv: [],
// Gets populated in `startMockServer()` // Gets populated in `startMockServer()`
apiUrl: '', apiUrl: '',
// Gets re-initialized for every test in `reset()`
argv: [],
authConfig: {}, authConfig: {},
stdin: new PassThrough(),
stdout: new PassThrough(),
output: new Output(),
config: {}, config: {},
localConfig: {}, localConfig: {},
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
output: new Output(new PassThrough()),
}); });
this.mockOutput = jest.fn();
this.app = express(); this.app = express();
this.app.use(express.json()); this.app.use(express.json());
@@ -57,33 +76,29 @@ export class MockClient extends Client {
} }
reset() { reset() {
this.stdin = new PassThrough(); this.stdin = new MockStream();
this.stdin.isTTY = true;
this.stdout = new PassThrough(); this.stdout = new MockStream();
this.stdout.isTTY = true; this.stdout.setEncoding('utf8');
this.stdout.end = () => {};
this.stdout.pause();
this.output = new Output(); this.stderr = new MockStream();
this.mockOutput = jest.fn(); this.stderr.setEncoding('utf8');
this.output.print = s => { this.stderr.end = () => {};
return this.mockOutput(s); this.stderr.pause();
}; this.stderr.isTTY = true;
this._createPromptModule();
this.output = new Output(this.stderr);
this.argv = []; this.argv = [];
this.authConfig = {}; this.authConfig = {};
this.config = {}; this.config = {};
this.localConfig = {}; this.localConfig = {};
// Just make this one silent
this.output.spinner = () => {};
this.scenario = Router(); this.scenario = Router();
this.output.isTTY = true;
}
get outputBuffer() {
return this.mockOutput.mock.calls.map(c => c[0]).join('');
} }
async startMockServer() { async startMockServer() {

View File

@@ -0,0 +1,65 @@
/**
* This file registers the custom Jest "matchers" that are useful for
* writing CLI unit tests, and sets them up to be recognized by TypeScript.
*
* References:
* - https://haspar.us/notes/adding-jest-custom-matchers-in-typescript
* - https://gist.github.com/hasparus/4ebaa17ec5d3d44607f522bcb1cda9fb
*/
/// <reference types="@types/jest" />
import * as matchers from './matchers';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Tail<T extends unknown[]> = T extends [infer _Head, ...infer Tail]
? Tail
: never;
type AnyFunction = (...args: any[]) => any;
type PromiseFunction = (...args: any[]) => Promise<any>;
type GetMatcherType<TP, TResult> = TP extends PromiseFunction
? (...args: Tail<Parameters<TP>>) => Promise<TResult>
: TP extends AnyFunction
? (...args: Tail<Parameters<TP>>) => TResult
: TP;
//type T = GetMatcherType<typeof matchers['toOutput'], void>;
type GetMatchersType<TMatchers, TResult> = {
[P in keyof TMatchers]: GetMatcherType<TMatchers[P], TResult>;
};
type FirstParam<T extends AnyFunction> = Parameters<T>[0];
type OnlyMethodsWhereFirstArgIsOfType<TObject, TWantedFirstArg> = {
[P in keyof TObject]: TObject[P] extends AnyFunction
? TWantedFirstArg extends FirstParam<TObject[P]>
? TObject[P]
: [
`Error: this function is present only when received is:`,
FirstParam<TObject[P]>
]
: TObject[P];
};
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Matchers<R, T = {}>
extends GetMatchersType<
OnlyMethodsWhereFirstArgIsOfType<typeof matchers, T>,
R
> {}
}
}
const jestExpect = (global as any).expect;
if (jestExpect !== undefined) {
jestExpect.extend(matchers);
} else {
console.error("Couldn't find Jest's global expect.");
}

View File

@@ -0,0 +1 @@
export * from './to-output';

View File

@@ -0,0 +1,67 @@
import {
getLabelPrinter,
matcherHint,
printExpected,
printReceived,
} from 'jest-matcher-utils';
import type { Readable } from 'stream';
import type { MatcherState } from 'expect';
import type { MatcherHintOptions } from 'jest-matcher-utils';
export async function toOutput(
this: MatcherState,
stream: Readable,
test: string,
timeout = 3000
) {
const { isNot } = this;
const matcherName = 'toOutput';
const matcherHintOptions: MatcherHintOptions = {
isNot,
promise: this.promise,
};
return new Promise(resolve => {
let output = '';
let timeoutId = setTimeout(onTimeout, timeout);
const message = () => {
const labelExpected = 'Expected output';
const labelReceived = 'Received output';
const printLabel = getLabelPrinter(labelExpected, labelReceived);
const hint =
matcherHint(matcherName, 'stream', 'test', matcherHintOptions) + '\n\n';
return (
hint +
printLabel(labelExpected) +
(isNot ? 'not ' : '') +
printExpected(test) +
'\n' +
printLabel(labelReceived) +
(isNot ? ' ' : '') +
printReceived(output)
);
};
function onData(data: string) {
output += data;
if (output.includes(test)) {
cleanup();
resolve({ pass: true, message });
}
}
function onTimeout() {
cleanup();
resolve({ pass: false, message });
}
function cleanup() {
clearTimeout(timeoutId);
stream.removeListener('data', onData);
stream.pause();
}
stream.on('data', onData);
stream.resume();
});
}

View File

@@ -10,38 +10,38 @@ import { useUser } from '../../mocks/user';
describe('deploy', () => { describe('deploy', () => {
it('should reject deploying a single file', async () => { it('should reject deploying a single file', async () => {
client.setArgv('deploy', __filename); client.setArgv('deploy', __filename);
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
`Error! Support for single file deployments has been removed.\nLearn More: https://vercel.link/no-single-file-deployments\n` `Error! Support for single file deployments has been removed.\nLearn More: https://vercel.link/no-single-file-deployments\n`
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying multiple files', async () => { it('should reject deploying multiple files', async () => {
client.setArgv('deploy', __filename, join(__dirname, 'inspect.test.ts')); client.setArgv('deploy', __filename, join(__dirname, 'inspect.test.ts'));
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
`Error! Can't deploy more than one path.\n` `Error! Can't deploy more than one path.\n`
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that does not exist', async () => { it('should reject deploying a directory that does not exist', async () => {
client.setArgv('deploy', 'does-not-exists'); client.setArgv('deploy', 'does-not-exists');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
`Error! The specified file or directory "does-not-exists" does not exist.\n` `Error! The specified file or directory "does-not-exists" does not exist.\n`
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that does not contain ".vercel/output" when `--prebuilt` is used', async () => { it('should reject deploying a directory that does not contain ".vercel/output" when `--prebuilt` is used', async () => {
client.setArgv('deploy', __dirname, '--prebuilt'); client.setArgv('deploy', __dirname, '--prebuilt');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The "--prebuilt" option was used, but no prebuilt output found in ".vercel/output". Run `vercel build` to generate a local build.\n' 'Error! The "--prebuilt" option was used, but no prebuilt output found in ".vercel/output". Run `vercel build` to generate a local build.\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that was built with a different target environment when `--prebuilt --prod` is used on "preview" output', async () => { it('should reject deploying a directory that was built with a different target environment when `--prebuilt --prod` is used on "preview" output', async () => {
@@ -56,14 +56,14 @@ describe('deploy', () => {
}); });
client.setArgv('deploy', cwd, '--prebuilt', '--prod'); client.setArgv('deploy', cwd, '--prebuilt', '--prod');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The "--prebuilt" option was used with the target environment "production",' + 'Error! The "--prebuilt" option was used with the target environment "production",' +
' but the prebuilt output found in ".vercel/output" was built with target environment "preview".' + ' but the prebuilt output found in ".vercel/output" was built with target environment "preview".' +
' Please run `vercel --prebuilt`.\n' + ' Please run `vercel --prebuilt`.\n' +
'Learn More: https://vercel.link/prebuilt-environment-mismatch\n' 'Learn More: https://vercel.link/prebuilt-environment-mismatch\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that was built with a different target environment when `--prebuilt` is used on "production" output', async () => { it('should reject deploying a directory that was built with a different target environment when `--prebuilt` is used on "production" output', async () => {
@@ -78,14 +78,14 @@ describe('deploy', () => {
}); });
client.setArgv('deploy', cwd, '--prebuilt'); client.setArgv('deploy', cwd, '--prebuilt');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The "--prebuilt" option was used with the target environment "preview",' + 'Error! The "--prebuilt" option was used with the target environment "preview",' +
' but the prebuilt output found in ".vercel/output" was built with target environment "production".' + ' but the prebuilt output found in ".vercel/output" was built with target environment "production".' +
' Please run `vercel --prebuilt --prod`.\n' + ' Please run `vercel --prebuilt --prod`.\n' +
'Learn More: https://vercel.link/prebuilt-environment-mismatch\n' 'Learn More: https://vercel.link/prebuilt-environment-mismatch\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying "version: 1"', async () => { it('should reject deploying "version: 1"', async () => {
@@ -94,11 +94,11 @@ describe('deploy', () => {
[fileNameSymbol]: 'vercel.json', [fileNameSymbol]: 'vercel.json',
version: 1, version: 1,
}; };
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The value of the `version` property within vercel.json can only be `2`.\n' 'Error! The value of the `version` property within vercel.json can only be `2`.\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying "version: {}"', async () => { it('should reject deploying "version: {}"', async () => {
@@ -108,10 +108,10 @@ describe('deploy', () => {
// @ts-ignore // @ts-ignore
version: {}, version: {},
}; };
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The `version` property inside your vercel.json file must be a number.\n' 'Error! The `version` property inside your vercel.json file must be a number.\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
}); });

View File

@@ -19,8 +19,12 @@ describe('env', () => {
name: 'vercel-env-pull', name: 'vercel-env-pull',
}); });
client.setArgv('env', 'pull', '--yes', '--cwd', cwd); client.setArgv('env', 'pull', '--yes', '--cwd', cwd);
const exitCode = await env(client); const exitCodePromise = env(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-env-pull'
);
await expect(client.stderr).toOutput('Created .env file');
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile(path.join(cwd, '.env')); const rawDevEnv = await fs.readFile(path.join(cwd, '.env'));
@@ -39,8 +43,12 @@ describe('env', () => {
name: 'vercel-env-pull', name: 'vercel-env-pull',
}); });
client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd); client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd);
const exitCode = await env(client); const exitCodePromise = env(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-env-pull'
);
await expect(client.stderr).toOutput('Created other.env file');
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env')); const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env'));
@@ -61,8 +69,12 @@ describe('env', () => {
}); });
client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd); client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd);
const exitCode = await env(client); const exitCodePromise = env(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-env-pull'
);
await expect(client.stderr).toOutput('Created other.env file');
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env')); const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env'));

View File

@@ -10,11 +10,9 @@ describe('inspect', () => {
client.setArgv('inspect', deployment.url); client.setArgv('inspect', deployment.url);
const exitCode = await inspect(client); const exitCode = await inspect(client);
expect(exitCode).toEqual(0); expect(exitCode).toEqual(0);
expect( await expect(client.stderr).toOutput(
client.mockOutput.mock.calls[0][0].startsWith(
`> Fetched deployment "${deployment.url}" in ${user.username}` `> Fetched deployment "${deployment.url}" in ${user.username}`
) );
).toBeTruthy();
}); });
it('should print error when deployment not found', async () => { it('should print error when deployment not found', async () => {
@@ -23,7 +21,7 @@ describe('inspect', () => {
client.setArgv('inspect', 'bad.com'); client.setArgv('inspect', 'bad.com');
const exitCode = await inspect(client); const exitCode = await inspect(client);
expect(exitCode).toEqual(1); expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual( await expect(client.stderr).toOutput(
`Error! Failed to find deployment "bad.com" in ${user.username}\n` `Error! Failed to find deployment "bad.com" in ${user.username}\n`
); );
}); });

View File

@@ -5,22 +5,45 @@ import { useUser } from '../../mocks/user';
describe('login', () => { describe('login', () => {
it('should not allow the `--token` flag', async () => { it('should not allow the `--token` flag', async () => {
client.setArgv('login', '--token', 'foo'); client.setArgv('login', '--token', 'foo');
const exitCode = await login(client); const exitCodePromise = login(client);
expect(exitCode).toEqual(2); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! `--token` may not be used with the "login" command\n' 'Error! `--token` may not be used with the "login" command\n'
); );
await expect(exitCodePromise).resolves.toEqual(2);
}); });
it('should allow login via email as argument', async () => { it('should allow login via email as argument', async () => {
const user = useUser(); const user = useUser();
client.setArgv('login', user.email); client.setArgv('login', user.email);
const exitCode = await login(client); const exitCodePromise = login(client);
expect(exitCode).toEqual(0); await expect(client.stderr).toOutput(
expect(
client.outputBuffer.includes(
`Success! Email authentication complete for ${user.email}` `Success! Email authentication complete for ${user.email}`
) );
).toEqual(true); await expect(exitCodePromise).resolves.toEqual(0);
});
describe('interactive', () => {
it('should allow login via email', async () => {
const user = useUser();
client.setArgv('login');
const exitCodePromise = login(client);
await expect(client.stderr).toOutput(`> Log in to Vercel`);
// Move down to "Email" option
client.stdin.write('\x1B[B'); // Down arrow
client.stdin.write('\x1B[B'); // Down arrow
client.stdin.write('\x1B[B'); // Down arrow
client.stdin.write('\r'); // Return key
await expect(client.stderr).toOutput('> Enter your email address:');
client.stdin.write(`${user.email}\n`);
await expect(client.stderr).toOutput(
`Success! Email authentication complete for ${user.email}`
);
await expect(exitCodePromise).resolves.toEqual(0);
});
}); });
}); });

View File

@@ -18,8 +18,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', cwd); client.setArgv('pull', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.development.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile( const rawDevEnv = await fs.readFile(
path.join(cwd, '.vercel', '.env.development.local') path.join(cwd, '.vercel', '.env.development.local')
@@ -29,23 +38,18 @@ describe('pull', () => {
}); });
it('should fail with message to pull without a link and without --env', async () => { it('should fail with message to pull without a link and without --env', async () => {
try { client.stdin.isTTY = false;
process.stdout.isTTY = undefined;
const cwd = setupFixture('vercel-pull-unlinked'); const cwd = setupFixture('vercel-pull-unlinked');
useUser(); useUser();
useTeams('team_dummy'); useTeams('team_dummy');
client.setArgv('pull', cwd); client.setArgv('pull', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(1); await expect(client.stderr).toOutput(
'Command `vercel pull` requires confirmation. Use option "--yes" to confirm.'
expect(client.outputBuffer).toMatch(
/Command `vercel pull` requires confirmation. Use option "--yes" to confirm./gm
); );
} finally { await expect(exitCodePromise).resolves.toEqual(1);
process.stdout.isTTY = true;
}
}); });
it('should fail without message to pull without a link and with --env', async () => { it('should fail without message to pull without a link and with --env', async () => {
@@ -54,12 +58,11 @@ describe('pull', () => {
useTeams('team_dummy'); useTeams('team_dummy');
client.setArgv('pull', cwd, '--yes'); client.setArgv('pull', cwd, '--yes');
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(1); await expect(client.stderr).not.toOutput(
'Command `vercel pull` requires confirmation. Use option "--yes" to confirm.'
expect(client.outputBuffer).not.toMatch(
/Command `vercel pull` requires confirmation. Use option "--yes" to confirm./gm
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should handle pulling with env vars (headless mode)', async () => { it('should handle pulling with env vars (headless mode)', async () => {
@@ -81,8 +84,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', cwd); client.setArgv('pull', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.development.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const config = await fs.readJSON(path.join(cwd, '.vercel/project.json')); const config = await fs.readJSON(path.join(cwd, '.vercel/project.json'));
expect(config).toMatchInlineSnapshot(` expect(config).toMatchInlineSnapshot(`
@@ -108,8 +120,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', '--environment=preview', cwd); client.setArgv('pull', '--environment=preview', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "preview" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.preview.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const rawPreviewEnv = await fs.readFile( const rawPreviewEnv = await fs.readFile(
path.join(cwd, '.vercel', '.env.preview.local') path.join(cwd, '.vercel', '.env.preview.local')
@@ -130,8 +151,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', '--environment=production', cwd); client.setArgv('pull', '--environment=production', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "production" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.production.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const rawProdEnv = await fs.readFile( const rawProdEnv = await fs.readFile(
path.join(cwd, '.vercel', '.env.production.local') path.join(cwd, '.vercel', '.env.production.local')

View File

@@ -14,14 +14,14 @@ describe('whoami', () => {
const user = useUser(); const user = useUser();
const exitCode = await whoami(client); const exitCode = await whoami(client);
expect(exitCode).toEqual(0); expect(exitCode).toEqual(0);
expect(client.outputBuffer).toEqual(`> ${user.username}\n`); await expect(client.stderr).toOutput(`> ${user.username}\n`);
}); });
it('should print only the Vercel username when output is not a TTY', async () => { it('should print only the Vercel username when output is not a TTY', async () => {
const user = useUser(); const user = useUser();
client.output.isTTY = false; client.stdout.isTTY = false;
const exitCode = await whoami(client); const exitCode = await whoami(client);
expect(exitCode).toEqual(0); expect(exitCode).toEqual(0);
expect(client.outputBuffer).toEqual(`${user.username}\n`); await expect(client.stdout).toOutput(`${user.username}\n`);
}); });
}); });

View File

@@ -1,25 +0,0 @@
import confirm from '../../src/util/input/confirm';
import { client } from '../mocks/client';
describe('MockClient', () => {
it('should mock `confirm()`', async () => {
// true
let confirmedPromise = confirm(client, 'Do the thing?', false);
client.stdin.write('yes\n');
client.stdout.setEncoding('utf8');
client.stdout.on('data', d => console.log({ d }));
let confirmed = await confirmedPromise;
expect(confirmed).toEqual(true);
// false
confirmedPromise = confirm(client, 'Do the thing?', false);
client.stdin.write('no\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(false);
});
});

View File

@@ -0,0 +1,34 @@
import confirm from '../../../src/util/input/confirm';
import { client } from '../../mocks/client';
describe('confirm()', () => {
it('should work with multiple prompts', async () => {
// true (explicit)
let confirmedPromise = confirm(client, 'Explictly true?', false);
await expect(client.stderr).toOutput('Explictly true? [y/N]');
client.stdin.write('yes\n');
let confirmed = await confirmedPromise;
expect(confirmed).toEqual(true);
// false (explicit)
confirmedPromise = confirm(client, 'Explcitly false?', true);
await expect(client.stderr).toOutput('Explcitly false? [Y/n]');
client.stdin.write('no\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(false);
// true (default)
confirmedPromise = confirm(client, 'Default true?', true);
await expect(client.stderr).toOutput('Default true? [Y/n]');
client.stdin.write('\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(true);
// false (default)
confirmedPromise = confirm(client, 'Default false?', false);
await expect(client.stderr).toOutput('Default false? [y/N]');
client.stdin.write('\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(false);
});
});

View File

@@ -22,10 +22,7 @@ describe('getRemoteUrl', () => {
client.output.debugEnabled = true; client.output.debugEnabled = true;
const data = await getRemoteUrl(join(dir, 'git/config'), client.output); const data = await getRemoteUrl(join(dir, 'git/config'), client.output);
expect(data).toBeNull(); expect(data).toBeNull();
expect( await expect(client.stderr).toOutput('Error while parsing repo data');
client.outputBuffer.includes('Error while parsing repo data'),
'Debug message was not found'
).toBeTruthy();
}); });
}); });

View File

@@ -1,18 +1,15 @@
import { join, sep } from 'path'; import { join, sep } from 'path';
// @ts-ignore - Missing types for "alpha-sort" // @ts-ignore - Missing types for "alpha-sort"
import { asc as alpha } from 'alpha-sort'; import { asc as alpha } from 'alpha-sort';
import createOutput from '../../../src/util/output';
import { staticFiles as getStaticFiles_ } from '../../../src/util/get-files'; import { staticFiles as getStaticFiles_ } from '../../../src/util/get-files';
import { client } from '../../mocks/client';
const output = createOutput({ debug: false });
const prefix = `${join(__dirname, '../../fixtures/unit')}${sep}`; const prefix = `${join(__dirname, '../../fixtures/unit')}${sep}`;
const base = (path: string) => path.replace(prefix, ''); const base = (path: string) => path.replace(prefix, '');
const fixture = (name: string) => join(prefix, name); const fixture = (name: string) => join(prefix, name);
const getStaticFiles = async (dir: string) => { const getStaticFiles = async (dir: string) => {
const files = await getStaticFiles_(dir, { const files = await getStaticFiles_(dir, client);
output,
});
return normalizeWindowsPaths(files); return normalizeWindowsPaths(files);
}; };

View File

@@ -27,14 +27,13 @@ describe('editProjectSettings', () => {
installCommand: null, installCommand: null,
outputDirectory: null, outputDirectory: null,
}); });
expect(client.mockOutput.mock.calls.length).toBe(5); await expect(client.stderr).toOutput(
expect(client.mockOutput.mock.calls[0][0]).toMatch( 'No framework detected. Default Project Settings:'
/No framework detected. Default Project Settings:/
); );
expect(client.mockOutput.mock.calls[1][0]).toMatch(/Build Command/); await expect(client.stderr).toOutput('Build Command');
expect(client.mockOutput.mock.calls[2][0]).toMatch(/Development Command/); await expect(client.stderr).toOutput('Development Command');
expect(client.mockOutput.mock.calls[3][0]).toMatch(/Install Command/); await expect(client.stderr).toOutput('Install Command');
expect(client.mockOutput.mock.calls[4][0]).toMatch(/Output Directory/); await expect(client.stderr).toOutput('Output Directory');
}); });
}); });
@@ -55,14 +54,13 @@ describe('editProjectSettings', () => {
null null
); );
expect(settings).toStrictEqual({ ...projectSettings, framework: null }); expect(settings).toStrictEqual({ ...projectSettings, framework: null });
expect(client.mockOutput.mock.calls.length).toBe(5); await expect(client.stderr).toOutput(
expect(client.mockOutput.mock.calls[0][0]).toMatch( 'No framework detected. Default Project Settings:'
/No framework detected. Default Project Settings:/
); );
expect(client.mockOutput.mock.calls[1][0]).toMatch(/Build Command/); await expect(client.stderr).toOutput('Build Command');
expect(client.mockOutput.mock.calls[2][0]).toMatch(/Development Command/); await expect(client.stderr).toOutput('Development Command');
expect(client.mockOutput.mock.calls[3][0]).toMatch(/Install Command/); await expect(client.stderr).toOutput('Install Command');
expect(client.mockOutput.mock.calls[4][0]).toMatch(/Output Directory/); await expect(client.stderr).toOutput('Output Directory');
}); });
}); });
@@ -82,18 +80,15 @@ describe('editProjectSettings', () => {
true, true,
null null
); );
expect(client.mockOutput.mock.calls.length).toBe(5);
expect(client.mockOutput.mock.calls[0][0]).toMatch(
/Auto-detected Project Settings/
);
expect(client.mockOutput.mock.calls[1][0]).toMatch(/Build Command/);
expect(client.mockOutput.mock.calls[2][0]).toMatch(/Development Command/);
expect(client.mockOutput.mock.calls[3][0]).toMatch(/Install Command/);
expect(client.mockOutput.mock.calls[4][0]).toMatch(/Output Directory/);
expect(settings).toStrictEqual({ expect(settings).toStrictEqual({
...projectSettings, ...projectSettings,
framework: nextJSFramework.slug, framework: nextJSFramework.slug,
}); });
await expect(client.stderr).toOutput('Auto-detected Project Settings');
await expect(client.stderr).toOutput('Build Command');
await expect(client.stderr).toOutput('Development Command');
await expect(client.stderr).toOutput('Install Command');
await expect(client.stderr).toOutput('Output Directory');
}); });
}); });
@@ -121,26 +116,20 @@ describe('editProjectSettings', () => {
true, true,
overrides overrides
); );
expect(client.mockOutput.mock.calls.length).toBe(9);
expect(client.mockOutput.mock.calls[0][0]).toMatch(
/Local settings detected in vercel.json:/
);
expect(client.mockOutput.mock.calls[1][0]).toMatch(/Build Command:/);
expect(client.mockOutput.mock.calls[2][0]).toMatch(/Ignore Command:/);
expect(client.mockOutput.mock.calls[3][0]).toMatch(
/Development Command:/
);
expect(client.mockOutput.mock.calls[4][0]).toMatch(/Framework:/);
expect(client.mockOutput.mock.calls[5][0]).toMatch(/Install Command:/);
expect(client.mockOutput.mock.calls[6][0]).toMatch(/Output Directory:/);
expect(client.mockOutput.mock.calls[7][0]).toMatch(
/Merging default Project Settings for Svelte. Previously listed overrides are prioritized./
);
expect(client.mockOutput.mock.calls[8][0]).toMatch(
/Auto-detected Project Settings/
);
expect(settings).toStrictEqual(overrides); expect(settings).toStrictEqual(overrides);
await expect(client.stderr).toOutput(
'Local settings detected in vercel.json:'
);
await expect(client.stderr).toOutput('Build Command:');
await expect(client.stderr).toOutput('Ignore Command:');
await expect(client.stderr).toOutput('Development Command:');
await expect(client.stderr).toOutput('Framework:');
await expect(client.stderr).toOutput('Install Command:');
await expect(client.stderr).toOutput('Output Directory:');
await expect(client.stderr).toOutput(
'Merging default Project Settings for Svelte. Previously listed overrides are prioritized.'
);
await expect(client.stderr).toOutput('Auto-detected Project Settings');
}); });
}); });
@@ -161,25 +150,20 @@ describe('editProjectSettings', () => {
true, true,
overrides overrides
); );
expect(client.mockOutput.mock.calls.length).toBe(9);
expect(client.mockOutput.mock.calls[0][0]).toMatch(
/Local settings detected in vercel.json:/
);
expect(client.mockOutput.mock.calls[1][0]).toMatch(/Build Command:/);
expect(client.mockOutput.mock.calls[2][0]).toMatch(/Ignore Command:/);
expect(client.mockOutput.mock.calls[3][0]).toMatch(
/Development Command:/
);
expect(client.mockOutput.mock.calls[4][0]).toMatch(/Framework:/);
expect(client.mockOutput.mock.calls[5][0]).toMatch(/Install Command:/);
expect(client.mockOutput.mock.calls[6][0]).toMatch(/Output Directory:/);
expect(client.mockOutput.mock.calls[7][0]).toMatch(
/Merging default Project Settings for Svelte. Previously listed overrides are prioritized./
);
expect(client.mockOutput.mock.calls[8][0]).toMatch(
/Auto-detected Project Settings/
);
expect(settings).toStrictEqual(overrides); expect(settings).toStrictEqual(overrides);
await expect(client.stderr).toOutput(
'Local settings detected in vercel.json:'
);
await expect(client.stderr).toOutput('Build Command:');
await expect(client.stderr).toOutput('Ignore Command:');
await expect(client.stderr).toOutput('Development Command:');
await expect(client.stderr).toOutput('Framework:');
await expect(client.stderr).toOutput('Install Command:');
await expect(client.stderr).toOutput('Output Directory:');
await expect(client.stderr).toOutput(
'Merging default Project Settings for Svelte. Previously listed overrides are prioritized.'
);
await expect(client.stderr).toOutput('Auto-detected Project Settings');
}); });
}); });
@@ -200,26 +184,20 @@ describe('editProjectSettings', () => {
true, true,
overrides overrides
); );
expect(client.mockOutput.mock.calls.length).toBe(9);
expect(client.mockOutput.mock.calls[0][0]).toMatch(
/Local settings detected in vercel.json:/
);
expect(client.mockOutput.mock.calls[1][0]).toMatch(/Build Command:/);
expect(client.mockOutput.mock.calls[2][0]).toMatch(/Ignore Command:/);
expect(client.mockOutput.mock.calls[3][0]).toMatch(
/Development Command:/
);
expect(client.mockOutput.mock.calls[4][0]).toMatch(/Framework:/);
expect(client.mockOutput.mock.calls[5][0]).toMatch(/Install Command:/);
expect(client.mockOutput.mock.calls[6][0]).toMatch(/Output Directory:/);
expect(client.mockOutput.mock.calls[7][0]).toMatch(
/Merging default Project Settings for Svelte. Previously listed overrides are prioritized./
);
expect(client.mockOutput.mock.calls[8][0]).toMatch(
/Auto-detected Project Settings/
);
expect(settings).toStrictEqual(overrides); expect(settings).toStrictEqual(overrides);
await expect(client.stderr).toOutput(
'Local settings detected in vercel.json:'
);
await expect(client.stderr).toOutput('Build Command:');
await expect(client.stderr).toOutput('Ignore Command:');
await expect(client.stderr).toOutput('Development Command:');
await expect(client.stderr).toOutput('Framework:');
await expect(client.stderr).toOutput('Install Command:');
await expect(client.stderr).toOutput('Output Directory:');
await expect(client.stderr).toOutput(
'Merging default Project Settings for Svelte. Previously listed overrides are prioritized.'
);
await expect(client.stderr).toOutput('Auto-detected Project Settings');
}); });
}); });
}); });