[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

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