From 8e58a93a942d5a0305a77a82e938f778c59fcd16 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 25 Jan 2022 12:07:39 -0700 Subject: [PATCH] feat: expose ux methods in SfCommand --- src/deauthorizer.ts | 2 +- src/deployer.ts | 8 +-- src/exported.ts | 6 +- src/hooks.ts | 2 +- src/prompter.ts | 22 ------- src/sfCommand.ts | 61 +++++++++++++++++- src/types/index.ts | 2 +- src/util.ts | 62 +++--------------- src/ux/index.ts | 11 ++++ src/ux/progress.ts | 117 ++++++++++++++++++++++++++++++++++ src/ux/prompter.ts | 71 +++++++++++++++++++++ src/{ux.ts => ux/spinner.ts} | 23 ++++--- src/ux/ux.ts | 41 ++++++++++++ test/unit/util.test.ts | 56 +++------------- test/unit/ux/progress.test.ts | 58 +++++++++++++++++ test/unit/ux/prompter.test.ts | 49 ++++++++++++++ test/unit/ux/spinner.test.ts | 69 ++++++++++++++++++++ test/unit/ux/ux.test.ts | 87 +++++++++++++++++++++++++ 18 files changed, 603 insertions(+), 144 deletions(-) delete mode 100644 src/prompter.ts create mode 100644 src/ux/index.ts create mode 100644 src/ux/progress.ts create mode 100644 src/ux/prompter.ts rename src/{ux.ts => ux/spinner.ts} (61%) create mode 100644 src/ux/ux.ts create mode 100644 test/unit/ux/progress.test.ts create mode 100644 test/unit/ux/prompter.test.ts create mode 100644 test/unit/ux/spinner.test.ts create mode 100644 test/unit/ux/ux.test.ts diff --git a/src/deauthorizer.ts b/src/deauthorizer.ts index b757cef1d..6205e82ab 100644 --- a/src/deauthorizer.ts +++ b/src/deauthorizer.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause diff --git a/src/deployer.ts b/src/deployer.ts index b86ac73a5..a64bed52d 100644 --- a/src/deployer.ts +++ b/src/deployer.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause @@ -7,9 +7,9 @@ import { EventEmitter } from 'events'; import { AnyJson, JsonMap } from '@salesforce/ts-types'; -import cli from 'cli-ux'; +import { ux } from 'cli-ux'; import { QuestionCollection } from 'inquirer'; -import { Prompter } from './prompter'; +import { Prompter } from './ux'; export abstract class Deployable { abstract getName(): string; @@ -40,7 +40,7 @@ export abstract class Deployer extends EventEmitter { * Log messages to the console */ public log(msg?: string | undefined, ...args: string[]): void { - cli.log(msg, ...args); + ux.log(msg, ...args); } /** diff --git a/src/exported.ts b/src/exported.ts index 49b62382f..dcc7d5874 100644 --- a/src/exported.ts +++ b/src/exported.ts @@ -1,14 +1,14 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -export { generateTableChoices, toHelpSection } from './util'; +export { toHelpSection } from './util'; export { Deployable, Deployer } from './deployer'; export { Deauthorizer } from './deauthorizer'; -export { Prompter } from './prompter'; +export { Progress, Prompter, generateTableChoices } from './ux'; export { SfHook } from './hooks'; export * from './types'; export { SfCommand, SfCommandInterface } from './sfCommand'; diff --git a/src/hooks.ts b/src/hooks.ts index 0885a08ff..f2a040222 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause diff --git a/src/prompter.ts b/src/prompter.ts deleted file mode 100644 index b7b396399..000000000 --- a/src/prompter.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import { prompt, QuestionCollection } from 'inquirer'; - -export class Prompter { - /** - * Prompt user for information. See https://www.npmjs.com/package/inquirer for more. - */ - public async prompt(questions: QuestionCollection, initialAnswers?: Partial): Promise { - const answers = await prompt(questions, initialAnswers); - return answers; - } -} - -export namespace Prompter { - export type Answers> = T & Record; -} diff --git a/src/sfCommand.ts b/src/sfCommand.ts index c54304192..0c39885e9 100644 --- a/src/sfCommand.ts +++ b/src/sfCommand.ts @@ -1,12 +1,14 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { Command, Config, HelpSection, Interfaces } from '@oclif/core'; +import { ux } from 'cli-ux'; import { Messages } from '@salesforce/core'; -import { Spinner } from './ux'; +import { AnyJson } from '@salesforce/ts-types'; +import { Progress, Prompter, Spinner, Ux } from './ux'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/sf-plugins-core', 'messages'); @@ -31,14 +33,22 @@ export abstract class SfCommand extends Command { public static configurationVariablesSection?: HelpSection; public static envVariablesSection?: HelpSection; public static errorCodes?: HelpSection; + public static tableFlags = ux.table.flags; public spinner: Spinner; + public progress: Progress; private warnings: SfCommand.Warning[] = []; + private ux: Ux; + private prompter: Prompter; public constructor(argv: string[], config: Config) { super(argv, config); - this.spinner = new Spinner(this.jsonEnabled()); + const outputEnabled = !this.jsonEnabled(); + this.spinner = new Spinner(outputEnabled); + this.progress = new Progress(outputEnabled); + this.ux = new Ux(outputEnabled); + this.prompter = new Prompter(); } /** @@ -60,6 +70,51 @@ export abstract class SfCommand extends Command { this.log(msg); } + /** + * Log a table to the console. Will automatically be suppressed when --json flag is present. + */ + public table(data: R[], columns: Ux.Table.Columns, options?: Ux.Table.Options): void { + this.ux.table(data, columns, options); + } + + /** + * Log a stylized url to the console. Will automatically be suppressed when --json flag is present. + */ + public url(text: string, uri: string, params = {}): void { + this.ux.url(text, uri, params); + } + + /** + * Log stylized JSON to the console. Will automatically be suppressed when --json flag is present. + */ + public styledJSON(obj: AnyJson): void { + this.ux.styledJSON(obj); + } + + /** + * Log stylized object to the console. Will automatically be suppressed when --json flag is present. + */ + public styledObject(obj: AnyJson): void { + this.ux.styledObject(obj); + } + + /** + * Prompt user for information. See https://www.npmjs.com/package/inquirer for more. + * + * This will NOT be automatically suppressed when the --json flag is present since we assume + * that any command that prompts the user for required information will not also support the --json flag. + * + * If you need to conditionally suppress prompts to support json output, then do the following: + * + * @example + * if (!this.jsonEnabled()) { + * await this.prompt(); + * } + */ + public async prompt(questions: Prompter.Questions, initialAnswers?: Partial): Promise { + return this.prompter.prompt(questions, initialAnswers); + } + /** * Wrap the command result into the standardized JSON structure. */ diff --git a/src/types/index.ts b/src/types/index.ts index c319fa6e6..9bd23d395 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause diff --git a/src/util.ts b/src/util.ts index c05731840..b63456e89 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,63 +1,19 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { Separator, ChoiceOptions, ChoiceBase } from 'inquirer'; -import { Dictionary, Nullable, ensureString } from '@salesforce/ts-types'; import { HelpSection } from '@oclif/core'; -import { ORG_CONFIG_ALLOWED_PROPERTIES, OrgConfigProperties } from '@salesforce/core'; -import { SFDX_ALLOWED_PROPERTIES, SfdxPropertyKeys } from '@salesforce/core'; -import { EnvironmentVariable, SUPPORTED_ENV_VARS } from '@salesforce/core'; - -/** - * Generate a formatted table for list and checkbox prompts - * - * Each option should contain the same keys as specified in columns. - * For example, - * const columns = { name: 'Name', type: 'Type', path: 'Path' }; - * const options = [{ name: 'foo', type: 'org', path: '/path/to/foo/' }]; - * generateTableChoices(columns, options); - */ -export function generateTableChoices( - columns: Dictionary, - choices: Array | T>>, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - padForCheckbox = true -): ChoiceBase[] { - const columnEntries = Object.entries(columns); - const columnLengths = columnEntries.map( - ([key, value]) => - Math.max( - ensureString(value).length, - ...choices.map( - (option) => - ensureString(option[key], `Type ${typeof option[key]} for ${key} in ${Object.keys(option).join(', ')}`) - .length - ) - ) + 1 - ); - - const choicesOptions: ChoiceBase[] = [ - new Separator( - `${padForCheckbox ? ' '.repeat(2) : ''}${columnEntries - .map(([, value], index) => value?.padEnd(columnLengths[index], ' ')) - .join('')}` - ), - ]; - - for (const meta of choices) { - const name = columnEntries - .map(([key], index) => ensureString(meta[key]).padEnd(columnLengths[index], ' ')) - .join(''); - const choice: ChoiceOptions = { name, value: meta.value, short: ensureString(meta.name) }; - choicesOptions.push(choice); - } - - return choicesOptions; -} +import { + ORG_CONFIG_ALLOWED_PROPERTIES, + OrgConfigProperties, + SFDX_ALLOWED_PROPERTIES, + SfdxPropertyKeys, + EnvironmentVariable, + SUPPORTED_ENV_VARS, +} from '@salesforce/core'; /** * Function to build a help section for command help. diff --git a/src/ux/index.ts b/src/ux/index.ts new file mode 100644 index 000000000..e5ba6110f --- /dev/null +++ b/src/ux/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +export { Ux } from './ux'; +export { Progress } from './progress'; +export { Prompter, generateTableChoices } from './prompter'; +export { Spinner } from './spinner'; diff --git a/src/ux/progress.ts b/src/ux/progress.ts new file mode 100644 index 000000000..f8e543fd7 --- /dev/null +++ b/src/ux/progress.ts @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as util from 'util'; +import { ux } from 'cli-ux'; +import { once } from '@salesforce/kit'; +import { Ux } from '.'; + +/** + * Class for display a progress bar to the console. Will automatically be suppressed if the --json flag is present. + */ +export class Progress extends Ux { + private static DEFAULT_OPTIONS = { + title: 'PROGRESS', + format: '%s | {bar} | {value}/{total} Components', + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + linewrap: true, + }; + + private bar!: Progress.Bar; + private total!: number; + + public constructor(outputEnabled: boolean) { + super(outputEnabled); + } + + /** + * Set the total number of expected components. + */ + public setTotal(total: number): void { + this.total = total; + this.bar.setTotal(total); + } + + /** + * Start the progress bar. + */ + public start( + total: number, + payload: Progress.Payload = {}, + options: Partial = Progress.DEFAULT_OPTIONS + ): void { + const opts = Object.assign(Progress.DEFAULT_OPTIONS, options); + opts.format = util.format(opts.format, opts.title); + this.bar = ux.progress({ + format: opts.format, + barCompleteChar: opts.barCompleteChar, + barIncompleteChar: opts.barIncompleteChar, + linewrap: opts.linewrap, + }) as Progress.Bar; + + this.bar.setTotal(total); + // this.maybeNoop(() => startProgressBar(this.bar, total, payload)); + this.maybeNoop(() => { + this._start(total, payload); + }); + } + + /** + * Update the progress bar. + */ + public update(num: number, payload = {}): void { + this.bar.update(num, payload); + } + + /** + * Update the progress bar with the final number and stop it. + */ + public finish(payload = {}): void { + this.bar.update(this.total, payload); + this.bar.stop(); + } + + /** + * Stop the progress bar. + */ + public stop(): void { + this.bar.stop(); + } + + private _start(total: number, payload: Progress.Payload = {}): void { + const start = once((bar: Progress.Bar, t: number, p: Progress.Payload = {}) => { + bar.start(t); + if (Object.keys(p).length) { + bar.update(0, p); + } + }); + start(this.bar, total, payload); + } +} + +export namespace Progress { + export type Bar = { + start: (num: number, payload?: unknown) => void; + update: (num: number, payload?: unknown) => void; + updateTotal: (num: number) => void; + setTotal: (num: number) => void; + stop: () => void; + }; + + export type Options = { + title: string; + format: string; + barCompleteChar: string; + barIncompleteChar: string; + linewrap: boolean; + /** output stream to use (default: process.stderr) */ + stream?: NodeJS.WritableStream | undefined; + }; + + export type Payload = Record; +} diff --git a/src/ux/prompter.ts b/src/ux/prompter.ts new file mode 100644 index 000000000..85ceb6ab0 --- /dev/null +++ b/src/ux/prompter.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { prompt, QuestionCollection, Separator, ChoiceOptions, ChoiceBase } from 'inquirer'; +import { Dictionary, Nullable, ensureString } from '@salesforce/ts-types'; + +export class Prompter { + /** + * Prompt user for information. See https://www.npmjs.com/package/inquirer for more. + */ + public async prompt(questions: Prompter.Questions, initialAnswers?: Partial): Promise { + const answers = await prompt(questions, initialAnswers); + return answers; + } +} + +export namespace Prompter { + export type Answers> = T & Record; + export type Questions = QuestionCollection; +} + +/** + * Generate a formatted table for list and checkbox prompts + * + * Each option should contain the same keys as specified in columns. + * For example, + * const columns = { name: 'Name', type: 'Type', path: 'Path' }; + * const options = [{ name: 'foo', type: 'org', path: '/path/to/foo/' }]; + * generateTableChoices(columns, options); + */ +export function generateTableChoices( + columns: Dictionary, + choices: Array | T>>, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + padForCheckbox = true +): ChoiceBase[] { + const columnEntries = Object.entries(columns); + const columnLengths = columnEntries.map( + ([key, value]) => + Math.max( + ensureString(value).length, + ...choices.map( + (option) => + ensureString(option[key], `Type ${typeof option[key]} for ${key} in ${Object.keys(option).join(', ')}`) + .length + ) + ) + 1 + ); + + const choicesOptions: ChoiceBase[] = [ + new Separator( + `${padForCheckbox ? ' '.repeat(2) : ''}${columnEntries + .map(([, value], index) => value?.padEnd(columnLengths[index], ' ')) + .join('')}` + ), + ]; + + for (const meta of choices) { + const name = columnEntries + .map(([key], index) => ensureString(meta[key]).padEnd(columnLengths[index], ' ')) + .join(''); + const choice: ChoiceOptions = { name, value: meta.value, short: ensureString(meta.name) }; + choicesOptions.push(choice); + } + + return choicesOptions; +} diff --git a/src/ux.ts b/src/ux/spinner.ts similarity index 61% rename from src/ux.ts rename to src/ux/spinner.ts index ea1ee2657..82a449500 100644 --- a/src/ux.ts +++ b/src/ux/spinner.ts @@ -1,51 +1,54 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { cli } from 'cli-ux'; +import { ux } from 'cli-ux'; +import { Ux } from '.'; /** - * This class is a light wrapper around cli.action that allows us to + * This class is a light wrapper around CliUx.ux.action that allows us to * automatically suppress any actions if `--json` flag is present. */ -export class Spinner { - public constructor(private jsonEnabled: boolean) {} +export class Spinner extends Ux { + public constructor(outputEnabled: boolean) { + super(outputEnabled); + } /** * Start a spinner on the console. */ public start(action: string, status?: string, opts?: { stdout?: boolean }): void { - if (!this.jsonEnabled) cli.action.start(action, status, opts); + this.maybeNoop(() => ux.action.start(action, status, opts)); } /** * Stop the spinner on the console. */ public stop(msg?: string): void { - if (!this.jsonEnabled) cli.action.stop(msg); + this.maybeNoop(() => ux.action.stop(msg)); } /** * Set the status of the current spinner. */ public set status(status: string | undefined) { - if (!this.jsonEnabled) cli.action.status = status; + ux.action.status = status; } /** * Get the status of the current spinner. */ public get status(): string | undefined { - return cli.action.status; + return ux.action.status; } /** * Pause the spinner on the console. */ public pause(fn: () => unknown, icon?: string): void { - if (!this.jsonEnabled) cli.action.pause(fn, icon); + this.maybeNoop(() => ux.action.pause(fn, icon)); } } diff --git a/src/ux/ux.ts b/src/ux/ux.ts new file mode 100644 index 000000000..eb8c0b378 --- /dev/null +++ b/src/ux/ux.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { ux, Table as UxTable } from 'cli-ux'; +import { AnyFunction, AnyJson } from '@salesforce/ts-types'; + +export class Ux { + public constructor(protected outputEnabled: boolean) {} + + public table(data: T[], columns: Ux.Table.Columns, options?: Ux.Table.Options): void { + this.maybeNoop(() => ux.table(data, columns, options)); + } + + public url(text: string, uri: string, params = {}): void { + this.maybeNoop(() => ux.url(text, uri, params)); + } + + public styledJSON(obj: AnyJson): void { + this.maybeNoop(() => ux.styledJSON(obj)); + } + + public styledObject(obj: AnyJson): void { + this.maybeNoop(() => ux.styledObject(obj)); + } + + protected maybeNoop(fn: AnyFunction): void { + if (this.outputEnabled) fn(); + } +} + +export namespace Ux { + export namespace Table { + export type Data = Record; + export type Columns = UxTable.table.Columns; + export type Options = UxTable.table.Options; + } +} diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 0f28ce3b7..bfeca9eda 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -1,56 +1,20 @@ /* - * Copyright (c) 2021, salesforce.com, inc. + * Copyright (c) 2022, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { expect } from 'chai'; -import { Separator } from 'inquirer'; -import stripAnsi = require('strip-ansi'); -import { EnvironmentVariable, SUPPORTED_ENV_VARS } from '@salesforce/core'; -import { ORG_CONFIG_ALLOWED_PROPERTIES, OrgConfigProperties } from '@salesforce/core'; -import { SfdxPropertyKeys, SFDX_ALLOWED_PROPERTIES } from '@salesforce/core'; -import { generateTableChoices, toHelpSection } from '../../src/util'; - -describe('generateTableChoices', () => { - const columns = { - name: 'APP OR PACKAGE', - type: 'TYPE', - path: 'PATH', - }; - const choices = [ - { - name: 'force-app', - type: 'org', - path: 'force-app', - value: 'force-app', - }, - { - name: 'my-app', - type: 'a-long-org', - path: 'my-app', - value: 'my-app', - }, - ]; - it('should generate a formatted table of choices', () => { - const tableChoices = generateTableChoices(columns, choices); - expect(tableChoices[0]).to.be.instanceof(Separator); - const separator = tableChoices[0] as typeof Separator; - expect(stripAnsi(separator.toString())).to.be.equal(' APP OR PACKAGE TYPE PATH '); - expect(tableChoices[1]).to.have.property('name').and.equal('force-app org force-app '); - expect(tableChoices[2]).to.have.property('name').and.equal('my-app a-long-org my-app '); - }); - - it('should generate a formatted table of choices without checkbox padding', () => { - const tableChoices = generateTableChoices(columns, choices, false); - expect(tableChoices[0]).to.be.instanceof(Separator); - const separator = tableChoices[0] as typeof Separator; - expect(stripAnsi(separator.toString())).to.be.equal('APP OR PACKAGE TYPE PATH '); - expect(tableChoices[1]).to.have.property('name').and.equal('force-app org force-app '); - expect(tableChoices[2]).to.have.property('name').and.equal('my-app a-long-org my-app '); - }); -}); +import { + EnvironmentVariable, + SUPPORTED_ENV_VARS, + ORG_CONFIG_ALLOWED_PROPERTIES, + OrgConfigProperties, + SfdxPropertyKeys, + SFDX_ALLOWED_PROPERTIES, +} from '@salesforce/core'; +import { toHelpSection } from '../../src/util'; describe('toHelpSection', () => { it('should produce help section for env vars', () => { diff --git a/test/unit/ux/progress.test.ts b/test/unit/ux/progress.test.ts new file mode 100644 index 000000000..2edbbca55 --- /dev/null +++ b/test/unit/ux/progress.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { Progress } from '../../../src/ux'; + +describe('Progress', () => { + let sandbox: sinon.SinonSandbox; + let writeStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + writeStub = sandbox.stub(process.stderr, 'write').withArgs(sinon.match('PROGRESS')).returns(true); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('start', () => { + it('should display a progress bar if output is enabled', () => { + const progress = new Progress(true); + progress.start(10); + progress.finish(); + expect(writeStub.firstCall.args[0]).to.match(/^PROGRESS\s\|\s(.*?)\s\|\s0\/10\sComponents/); + }); + + it('should not display anything if output is not enabled', () => { + const progress = new Progress(false); + progress.start(10); + progress.finish(); + expect(writeStub.callCount).to.equal(0); + }); + }); + + describe('update', () => { + it('should update the progress bar', () => { + const progress = new Progress(true); + progress.start(10); + progress.update(5); + progress.finish(); + expect(writeStub.lastCall.args[0]).to.match(/^PROGRESS\s\|\s(.*?)\s\|\s5\/10\sComponents/); + }); + }); + + describe('stop', () => { + it('should stop the progress bar', () => { + const progress = new Progress(true); + progress.start(10); + progress.stop(); + expect(writeStub.lastCall.args[0]).to.match(/^PROGRESS\s\|\s(.*?)\s\|\s0\/10\sComponents/); + }); + }); +}); diff --git a/test/unit/ux/prompter.test.ts b/test/unit/ux/prompter.test.ts new file mode 100644 index 000000000..ab78f4213 --- /dev/null +++ b/test/unit/ux/prompter.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { expect } from 'chai'; +import stripAnsi = require('strip-ansi'); +import { Separator } from 'inquirer'; +import { generateTableChoices } from '../../../src/ux'; + +describe('generateTableChoices', () => { + const columns = { + name: 'APP OR PACKAGE', + type: 'TYPE', + path: 'PATH', + }; + const choices = [ + { + name: 'force-app', + type: 'org', + path: 'force-app', + value: 'force-app', + }, + { + name: 'my-app', + type: 'a-long-org', + path: 'my-app', + value: 'my-app', + }, + ]; + it('should generate a formatted table of choices', () => { + const tableChoices = generateTableChoices(columns, choices); + expect(tableChoices[0]).to.be.instanceof(Separator); + const separator = tableChoices[0] as typeof Separator; + expect(stripAnsi(separator.toString())).to.be.equal(' APP OR PACKAGE TYPE PATH '); + expect(tableChoices[1]).to.have.property('name').and.equal('force-app org force-app '); + expect(tableChoices[2]).to.have.property('name').and.equal('my-app a-long-org my-app '); + }); + + it('should generate a formatted table of choices without checkbox padding', () => { + const tableChoices = generateTableChoices(columns, choices, false); + expect(tableChoices[0]).to.be.instanceof(Separator); + const separator = tableChoices[0] as typeof Separator; + expect(stripAnsi(separator.toString())).to.be.equal('APP OR PACKAGE TYPE PATH '); + expect(tableChoices[1]).to.have.property('name').and.equal('force-app org force-app '); + expect(tableChoices[2]).to.have.property('name').and.equal('my-app a-long-org my-app '); + }); +}); diff --git a/test/unit/ux/spinner.test.ts b/test/unit/ux/spinner.test.ts new file mode 100644 index 000000000..ac7e564bf --- /dev/null +++ b/test/unit/ux/spinner.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { ux as cliUx } from 'cli-ux'; +import { Spinner } from '../../../src/ux'; + +describe('Spinner', () => { + let sandbox: sinon.SinonSandbox; + let writeStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + // @ts-expect-error because _write is a protected member + writeStub = sandbox.stub(cliUx.action, '_write'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('start/stop', () => { + it('should start a spinner if output is enabled', () => { + const spinner = new Spinner(true); + spinner.start('Doing things'); + spinner.stop('Finished'); + expect(writeStub.firstCall.args).to.deep.equal(['stderr', 'Doing things...']); + }); + + it('should not log anything if output is not enabled', () => { + const spinner = new Spinner(false); + spinner.start('Doing things'); + spinner.stop('Finished'); + expect(writeStub.callCount).to.equal(0); + }); + }); + + describe('pause', () => { + it('should pause the spinner if output is enabled', () => { + const spinner = new Spinner(true); + spinner.start('Doing things'); + spinner.pause(() => {}); + spinner.stop('Finished'); + expect(writeStub.firstCall.args).to.deep.equal(['stderr', 'Doing things...']); + }); + + it('should not log anything if output is not enabled', () => { + const spinner = new Spinner(false); + spinner.start('Doing things'); + spinner.pause(() => {}); + spinner.stop('Finished'); + expect(writeStub.callCount).to.equal(0); + }); + }); + + describe('status', () => { + it('should set the status of the spinner', () => { + const spinner = new Spinner(true); + spinner.start('Doing things'); + spinner.status = 'running'; + expect(spinner.status).to.equal('running'); + spinner.stop('Finished'); + }); + }); +}); diff --git a/test/unit/ux/ux.test.ts b/test/unit/ux/ux.test.ts new file mode 100644 index 000000000..5e04d71af --- /dev/null +++ b/test/unit/ux/ux.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2022, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { ux as cliUx } from 'cli-ux'; +import { Ux } from '../../../src/ux'; + +describe('Ux', () => { + let sandbox: sinon.SinonSandbox; + let infoStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + infoStub = sandbox.stub(cliUx, 'info').callsFake(() => {}); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('table', () => { + it('should log a table', () => { + const ux = new Ux(true); + ux.table([{ key: 'foo', value: 'bar' }], { key: {}, value: {} }, { printLine: cliUx.info }); + expect(infoStub.args).to.deep.equal([ + ['\u001b[1m Key Value \u001b[22m'], + ['\u001b[1m ─── ───── \u001b[22m'], + [' foo bar '], + ]); + }); + + it('should not log anything if output is not enabled', () => { + const ux = new Ux(false); + ux.table([{ key: 'foo', value: 'bar' }], { key: {}, value: {} }); + expect(infoStub.callCount).to.equal(0); + }); + }); + + describe('url', () => { + it('should log a url', () => { + const ux = new Ux(true); + ux.url('Salesforce', 'https://developer.salesforce.com/'); + expect(infoStub.firstCall.args).to.deep.equal(['https://developer.salesforce.com/']); + }); + + it('should not log anything if output is not enabled', () => { + const ux = new Ux(false); + ux.url('Salesforce', 'https://developer.salesforce.com/'); + expect(infoStub.callCount).to.equal(0); + }); + }); + + describe('styledJSON', () => { + it('should log stylized json', () => { + const ux = new Ux(true); + ux.styledJSON({ foo: 'bar' }); + expect(infoStub.firstCall.args).to.deep.equal([ + '\x1B[97m{\x1B[39m\n \x1B[94m"foo"\x1B[39m\x1B[93m:\x1B[39m \x1B[92m"bar"\x1B[39m\n\x1B[97m}\x1B[39m', + ]); + }); + + it('should not log anything if output is not enabled', () => { + const ux = new Ux(false); + ux.styledJSON({ foo: 'bar' }); + expect(infoStub.callCount).to.equal(0); + }); + }); + + describe('styledObject', () => { + it('should log stylized object', () => { + const ux = new Ux(true); + ux.styledObject({ foo: 'bar' }); + expect(infoStub.firstCall.args).to.deep.equal(['\u001b[34mfoo\u001b[39m: bar']); + }); + + it('should not log anything if output is not enabled', () => { + const ux = new Ux(false); + ux.styledObject({ foo: 'bar' }); + expect(infoStub.callCount).to.equal(0); + }); + }); +});