diff --git a/.mocharc.yml b/.mocharc.yml index f69495a0..57d77c92 100644 --- a/.mocharc.yml +++ b/.mocharc.yml @@ -1,3 +1,4 @@ recursive: true timeout: '10000' -reporter: 'spec' \ No newline at end of file +reporter: 'spec' +require: 'test/helpers/setup.js' diff --git a/src/box-command.js b/src/box-command.js index 0cd1e4e5..d9eb0597 100644 --- a/src/box-command.js +++ b/src/box-command.js @@ -37,6 +37,7 @@ const inquirer = require('./inquirer'); const { stringifyStream } = require('@discoveryjs/json-ext'); const progress = require('cli-progress'); const secureStorage = require('./secure-storage'); +const { ProgramStatus } = require('./program-status'); const DEBUG = require('./debug'); const stream = require('node:stream'); @@ -323,6 +324,11 @@ class BoxCommand extends Command { this.flags = flags; this.args = args; this.settings = await this._loadSettings(); + this._programStatus = new ProgramStatus({ + settings: this.settings, + label: this.id ? `box ${this.id}` : 'box', + }); + this.programStatus.start(); this.client = await this.getClient(); this.tsClient = await this.getTsClient(); @@ -369,6 +375,9 @@ class BoxCommand extends Command { stopOnComplete: true, }); progressBar.start(bulkCalls.length, 0); + const bulkStatusMessage = (count) => + `Bulk ${this.id}: ${count}/${bulkCalls.length} entries`; + this.programStatus.working(bulkStatusMessage(0), 0); for (let bulkData of bulkCalls) { this.argv = []; @@ -396,6 +405,19 @@ class BoxCommand extends Command { } progressBar.update(bulkEntryIndex); + this.programStatus.working( + bulkStatusMessage(bulkEntryIndex), + (bulkEntryIndex / bulkCalls.length) * 100 + ); + } + if (this.bulkErrors.length > 0) { + this.programStatus.fail( + `Bulk ${this.id}: ${this.bulkErrors.length} of ${bulkCalls.length} entries failed` + ); + } else { + this.programStatus.succeed( + `Bulk ${this.id}: processed ${bulkCalls.length} entries` + ); } this.isBulk = false; DEBUG.execute('Leaving bulk mode and writing final output'); @@ -1586,18 +1608,93 @@ class BoxCommand extends Command { return true; } - let answers = await inquirer.prompt([ - { - name: 'confirmation', - message: promptText, - type: 'confirm', - default: defaultValue, - }, - ]); + let answers = await this.prompt( + [ + { + name: 'confirmation', + message: promptText, + type: 'confirm', + default: defaultValue, + }, + ], + { kind: 'permission' } + ); + if (!answers.confirmation && !this.isBulk) { + this.programStatus.cancel(); + } return answers.confirmation; } + /** + * Prompt the user, reporting to the terminal that the command is waiting on them + * + * @param {Object[]} questions Inquirer questions + * @param {Object} [options] Status options + * @param {string} [options.kind] permission, question, or auth + * @param {string} [options.msg] What the command is waiting for; defaults to the first question's message + * @returns {Promise} The answers + */ + async prompt(questions, { kind = 'question', msg } = {}) { + this.programStatus.blocked(kind, msg ?? questions[0]?.message); + try { + return await inquirer.prompt(questions); + } finally { + this.programStatus.resume(); + } + } + + /** + * Status reporting for the current command run + * + * @returns {ProgramStatus} The status reporter; reports nothing before init() + */ + get programStatus() { + if (!this._programStatus) { + this._programStatus = new ProgramStatus({ enabled: false }); + } + return this._programStatus; + } + + /** + * Report what the command is working on. Ignored in bulk mode, which reports + * progress across all entries instead. + * + * @param {string} msg What the command is doing + * @param {number} [progress] Percentage complete; indeterminate if omitted + * @returns {void} + */ + reportStatus(msg, progress) { + if (!this.isBulk) { + this.programStatus.working(msg, progress); + } + } + + /** + * Set the message reported when the command finishes. Ignored in bulk mode. + * + * @param {string} msg What the command finished + * @returns {void} + */ + setStatusResult(msg) { + if (!this.isBulk) { + this.programStatus.succeed(msg); + } + } + + /** + * Mark the command as failed when it reports a failure without throwing. + * Ignored in bulk mode. + * + * @param {string} msg What failed + * @returns {void} + */ + setStatusFailed(msg) { + if (!this.isBulk) { + this.programStatus.fail(msg); + } + } + /** * Writes output to stderr — this should be used for informational output. For example, a message * stating that an item has been deleted. @@ -1705,6 +1802,15 @@ class BoxCommand extends Command { DEBUG.execute('Error parsing BoxSdkError message: %s', error); } } + if (err?.code !== 'EEXIT') { + const statusMessage = + typeof err?.message === 'string' + ? this.wrapError(err).message + : String(err); + this.programStatus.fail( + `${this.programStatus.label} failed: ${statusMessage}` + ); + } try { // Let the oclif default handler run first, since it handles the help and version flags there /* eslint-disable promise/no-promise-in-callback */ @@ -1755,8 +1861,9 @@ class BoxCommand extends Command { * @param {Error} [err] An error, if one occurred * @returns {void} */ - async finally(/* err */) { + async finally(err) { // called after run and catch regardless of whether or not the command errored + this.programStatus.finish(err); } /** diff --git a/src/commands/configure/environments/delete.js b/src/commands/configure/environments/delete.js index bd078ed3..c852aeca 100644 --- a/src/commands/configure/environments/delete.js +++ b/src/commands/configure/environments/delete.js @@ -3,7 +3,6 @@ const { Args } = require('@oclif/core'); const BoxCommand = require('../../../box-command'); const BoxCLIError = require('../../../cli-error'); -const inquirer = require('../../../inquirer'); class EnvironmentsDeleteCommand extends BoxCommand { async run() { @@ -16,14 +15,17 @@ class EnvironmentsDeleteCommand extends BoxCommand { if (choices.length === 0) { throw new BoxCLIError('No environments to delete'); } - let answers = await inquirer.prompt([ - { - type: 'list', - name: 'environment', - message: 'Which environment?', - choices: Object.keys(environmentsObject.environments), - }, - ]); + let answers = await this.prompt( + [ + { + type: 'list', + name: 'environment', + message: 'Which environment?', + choices: Object.keys(environmentsObject.environments), + }, + ], + { msg: 'Choose the Box environment to delete' } + ); name = answers.environment; } diff --git a/src/commands/configure/environments/set-current.js b/src/commands/configure/environments/set-current.js index 29f9c3d0..d2a4dc0e 100644 --- a/src/commands/configure/environments/set-current.js +++ b/src/commands/configure/environments/set-current.js @@ -2,7 +2,6 @@ const { Args } = require('@oclif/core'); const BoxCommand = require('../../../box-command'); -const inquirer = require('../../../inquirer'); class EnvironmentsSetCurrentCommand extends BoxCommand { async run() { @@ -11,14 +10,17 @@ class EnvironmentsSetCurrentCommand extends BoxCommand { let name = args.id; if (!name) { - let answers = await inquirer.prompt([ - { - type: 'list', - name: 'environment', - message: 'Which environment?', - choices: Object.keys(environmentsObject.environments), - }, - ]); + let answers = await this.prompt( + [ + { + type: 'list', + name: 'environment', + message: 'Which environment?', + choices: Object.keys(environmentsObject.environments), + }, + ], + { msg: 'Choose the Box environment to use' } + ); name = answers.environment; } diff --git a/src/commands/files/download.js b/src/commands/files/download.js index 12279d9a..26053a48 100644 --- a/src/commands/files/download.js +++ b/src/commands/files/download.js @@ -70,6 +70,12 @@ class FilesDownloadCommand extends BoxCommand { progressBar.start(file.size, 0, { speed: 'N/A' }); let downloadedByte = 0; + const reportDownloadStatus = () => + this.reportStatus( + `Downloading ${fileName}`, + file.size > 0 ? (downloadedByte / file.size) * 100 : undefined + ); + reportDownloadStatus(); stream.on('data', (chunk) => { downloadedByte += chunk.length; }); @@ -79,6 +85,7 @@ class FilesDownloadCommand extends BoxCommand { downloadedByte / (Date.now() - startTime) / 1000 ), }); + reportDownloadStatus(); }, 1000); // We need to await the end of the stream to avoid a race condition here @@ -89,6 +96,7 @@ class FilesDownloadCommand extends BoxCommand { clearInterval(intervalUpdate); progressBar.update(downloadedByte); this.info(`Downloaded file ${fileName}`); + this.setStatusResult(`Downloaded ${fileName}`); } } diff --git a/src/commands/files/upload.js b/src/commands/files/upload.js index 8ff9a620..de142c76 100644 --- a/src/commands/files/upload.js +++ b/src/commands/files/upload.js @@ -34,6 +34,11 @@ class FilesUploadCommand extends BoxCommand { fileAttributes.content_modified_at = flags['content-modified-at']; } + const statusMessage = `Uploading ${name}`; + const onProgress = (percentage) => + this.reportStatus(statusMessage, percentage); + this.reportStatus(statusMessage); + let file; try { file = await uploadFile(this.client, { @@ -42,6 +47,7 @@ class FilesUploadCommand extends BoxCommand { stream, size, fileAttributes, + onProgress, }); } catch (error) { const { statusCode, response } = error; @@ -88,9 +94,11 @@ class FilesUploadCommand extends BoxCommand { stream: versionStream, size, fileAttributes, + onProgress, }); } + this.setStatusResult(`Uploaded ${name}`); await this.output(file.entries[0]); } } diff --git a/src/commands/files/versions/download.js b/src/commands/files/versions/download.js index 42ceed83..b39acb8d 100644 --- a/src/commands/files/versions/download.js +++ b/src/commands/files/versions/download.js @@ -63,6 +63,7 @@ class FilesVersionsDownloadCommand extends BoxCommand { } // @TODO(2018-09-18): Add progress bar for large downloads + this.reportStatus(`Downloading ${fileName}`); // We need to await the end of the stream to avoid a race condition here await new Promise((resolve, reject) => { @@ -70,6 +71,7 @@ class FilesVersionsDownloadCommand extends BoxCommand { stream.on('error', reject); }); this.info(`Downloaded file ${fileName}`); + this.setStatusResult(`Downloaded ${fileName}`); } } diff --git a/src/commands/files/versions/upload.js b/src/commands/files/versions/upload.js index 2618433a..6741d5f2 100644 --- a/src/commands/files/versions/upload.js +++ b/src/commands/files/versions/upload.js @@ -24,12 +24,17 @@ class FilesUploadVersionsCommand extends BoxCommand { fileAttributes.name = flags.name; } + const statusMessage = `Uploading a new version of file ${args.fileID}`; + this.reportStatus(statusMessage); const file = await uploadNewFileVersion(this.client, { fileID: args.fileID, stream, size, fileAttributes, + onProgress: (percentage) => + this.reportStatus(statusMessage, percentage), }); + this.setStatusResult(`Uploaded a new version of file ${args.fileID}`); await this.output(file.entries[0]); } } diff --git a/src/commands/files/zip.js b/src/commands/files/zip.js index 0d09e2dc..b6276d54 100644 --- a/src/commands/files/zip.js +++ b/src/commands/files/zip.js @@ -48,6 +48,7 @@ class FilesZipCommand extends BoxCommand { let downloadStatus; let outputFinished = false; + this.reportStatus(`Downloading zip ${fileName}`); await new Promise((resolve, reject) => { /* eslint-disable promise/always-return */ @@ -69,6 +70,7 @@ class FilesZipCommand extends BoxCommand { }); output.on('error', reject); }); + this.setStatusResult(`Downloaded zip ${fileName}`); await this.output(downloadStatus); } } diff --git a/src/commands/folders/download.js b/src/commands/folders/download.js index 41dca980..8b3c8fa9 100644 --- a/src/commands/folders/download.js +++ b/src/commands/folders/download.js @@ -73,6 +73,8 @@ class FoldersDownloadCommand extends BoxCommand { } this.spinner = ora('Starting download').start(); + let downloadedFileCount = 0; + this.reportStatus(`Downloading folder ${id}`); if (flags.zip) { this.overwrite = true; @@ -112,6 +114,9 @@ class FoldersDownloadCommand extends BoxCommand { this.spinnerLog( `Downloading file ${item.id} to ${item.path}` ); + this.reportStatus( + `Downloading folder ${id}: ${downloadedFileCount} files downloaded` + ); let stream = await this.client.files.getReadStream(item.id); if (this.zip) { @@ -120,6 +125,7 @@ class FoldersDownloadCommand extends BoxCommand { // @TODO(2018-08-15): Improve performance by queueing async work and performing in parallel await saveFileToDisk(destinationPath, item, stream); } + downloadedFileCount += 1; } } } catch (error) { @@ -128,9 +134,13 @@ class FoldersDownloadCommand extends BoxCommand { } if (this.zip) { + this.reportStatus(`Writing zip archive for folder ${id}`); this.zip.finalize(); } await outputFinalized; + this.setStatusResult( + `Downloaded folder ${id} (${downloadedFileCount} files)` + ); this.spinner.succeed( `${this.bufferLog || ''}\nDownloaded folder ${id} to ${path.join( this.outputPath, diff --git a/src/commands/folders/upload.js b/src/commands/folders/upload.js index aad46757..0504f010 100644 --- a/src/commands/folders/upload.js +++ b/src/commands/folders/upload.js @@ -12,12 +12,18 @@ class FoldersUploadCommand extends BoxCommand { async run() { const { flags, args } = await this.parse(FoldersUploadCommand); + this.uploadedFileCount = 0; + this.statusFolderName = + flags['folder-name'] || path.basename(args.path); let folderId = await this.uploadFolder( args.path, flags['parent-folder'], flags['folder-name'] ); let folder = await this.client.folders.get(folderId); + this.setStatusResult( + `Uploaded folder ${this.statusFolderName} (${this.uploadedFileCount} files)` + ); await this.output(folder); } @@ -53,6 +59,9 @@ class FoldersUploadCommand extends BoxCommand { await this.uploadFolder(itemPath, folderId); } else { let size = itemStat.size; + this.reportStatus( + `Uploading folder ${this.statusFolderName}: ${this.uploadedFileCount} files uploaded` + ); try { let fileStream = fs.createReadStream(itemPath); @@ -78,6 +87,7 @@ class FoldersUploadCommand extends BoxCommand { error ); } + this.uploadedFileCount += 1; } } return folderId; diff --git a/src/commands/login.js b/src/commands/login.js index 054f7492..5c7d170d 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -9,7 +9,6 @@ const CLITokenCache = require('../token-cache'); const package_ = require('../../package.json'); const chalk = require('chalk'); const express = require('express'); -const inquirer = require('../inquirer'); const path = require('node:path'); const ora = require('ora'); const http = require('node:http'); @@ -43,6 +42,13 @@ const DEFAULT_OPEN_AUTHORIZE_IN_BROWSER = ( } }; +const PROMPT_STATUS = Object.freeze({ + choice: { kind: 'question', msg: 'Choose how to sign in to Box' }, + clientId: { kind: 'auth', msg: 'Enter the Client ID' }, + clientSecret: { kind: 'auth', msg: 'Enter the Client Secret' }, + code: { kind: 'auth', msg: 'Enter the authorization code and state' }, +}); + let oauthCallbackTimeoutMs = OAUTH_CALLBACK_TIMEOUT_MS; let openAuthorizeInBrowser = DEFAULT_OPEN_AUTHORIZE_IN_BROWSER; @@ -126,6 +132,10 @@ class OAuthLoginCommand extends BoxCommand { const apps = openModule.apps; const { flags } = await this.parse(OAuthLoginCommand); + const prompter = { + prompt: (questions) => + this.prompt(questions, PROMPT_STATUS[questions[0]?.name]), + }; const forceDefaultBoxApp = flags['default-box-app']; const forcePlatformApp = flags['platform-app']; let useDefaultBoxApp = false; @@ -153,6 +163,9 @@ class OAuthLoginCommand extends BoxCommand { this.info( chalk`{red The "${this.flags.name}" environment does not exist}` ); + this.setStatusFailed( + `The "${this.flags.name}" environment does not exist` + ); return; } } @@ -162,6 +175,9 @@ class OAuthLoginCommand extends BoxCommand { this.info( chalk`{red The selected environment is not of type oauth20}` ); + this.setStatusFailed( + 'The selected environment is not of type oauth20' + ); return; } if (forceDefaultBoxApp) { @@ -201,7 +217,7 @@ class OAuthLoginCommand extends BoxCommand { authMethod: 'oauth20', }; } else if (forcePlatformApp) { - const answers = await promptForPlatformAppCredentials(inquirer); + const answers = await promptForPlatformAppCredentials(prompter); useDefaultBoxApp = false; environment = { @@ -212,8 +228,9 @@ class OAuthLoginCommand extends BoxCommand { authMethod: 'oauth20', }; } else { - const answers = await promptForAuthMethod(inquirer); + const answers = await promptForAuthMethod(prompter); if (answers === null) { + this.programStatus.cancel('Cancelled sign-in to Box'); return; } useDefaultBoxApp = answers.useDefaultBoxApp; @@ -231,6 +248,9 @@ class OAuthLoginCommand extends BoxCommand { this.info( chalk`{red Unsupported port "${port}" for the Official Box CLI app flow. Supported ports: ${SUPPORTED_DEFAULT_APP_PORTS.join(', ')}}` ); + this.setStatusFailed( + `Unsupported port ${port} for the Official Box CLI app flow` + ); return; } @@ -314,6 +334,7 @@ class OAuthLoginCommand extends BoxCommand { this.info( chalk`{red Login timed out waiting for OAuth callback after ${oauthCallbackTimeoutMs / 1000} seconds.}` ); + this.setStatusFailed('Login timed out waiting for authorization'); finalizeCallbackFlow(); }, oauthCallbackTimeoutMs); @@ -329,6 +350,7 @@ class OAuthLoginCommand extends BoxCommand { } callbackHandled = true; + this.reportStatus('Completing sign-in to Box'); try { if (request.query.state !== state) { @@ -372,6 +394,7 @@ class OAuthLoginCommand extends BoxCommand { this.info( chalk`{green Successfully logged in as ${user.login}!}` ); + this.setStatusResult(`Logged in to Box as ${user.login}`); if (this.flags.reauthorize) { this.info( chalk`{green Environment "${environmentName}" has been updated, selected and it's ready to use.}` @@ -396,6 +419,7 @@ class OAuthLoginCommand extends BoxCommand { 'Unknown error'; DEBUG.execute('Login error: %O', error); this.info(chalk`{red Login failed: ${errorMessage}}`); + this.setStatusFailed(`Login failed: ${errorMessage}`); res.status(500).send( 'Login failed. Please check the CLI output for details.' ); @@ -431,7 +455,7 @@ class OAuthLoginCommand extends BoxCommand { this.info( chalk`{yellow If you are not seeing your code, please make sure you have set up the Redirect URI.}` ); - const authInfo = await inquirer.prompt([ + const authInfo = await prompter.prompt([ { type: 'input', name: 'code', @@ -458,6 +482,12 @@ class OAuthLoginCommand extends BoxCommand { ? chalk`{yellow If authorization fails, verify that you are using one of the supported ports for the Official Box CLI app flow and restart the login command.}` : chalk`{yellow If you are redirected to the Files view, make sure your Redirect URI is configured correctly and restart the login command.}` ); + if (!callbackHandled) { + this.programStatus.blocked( + 'auth', + 'Waiting for you to grant access to Box in the browser' + ); + } } await callbackFlowDone; } diff --git a/src/commands/logout.js b/src/commands/logout.js index e5117df3..d2204249 100644 --- a/src/commands/logout.js +++ b/src/commands/logout.js @@ -4,7 +4,6 @@ const BoxCommand = require('../box-command'); const BoxSDK = require('box-node-sdk').default; const CLITokenCache = require('../token-cache'); const chalk = require('chalk'); -const inquirer = require('../inquirer'); const pkg = require('../../package.json'); const { Flags } = require('@oclif/core'); @@ -131,6 +130,9 @@ class OAuthLogoutCommand extends BoxCommand { this.info( chalk`{yellow Logout aborted. Token was not revoked and remains cached.}` ); + this.setStatusFailed( + `Logout from "${currentEnv}" aborted: token was not revoked` + ); return; } if (action === 'clear') { @@ -144,6 +146,7 @@ class OAuthLogoutCommand extends BoxCommand { this.info( chalk`{green Successfully logged out from "${currentEnv}" environment.}` ); + this.setStatusResult(`Logged out from "${currentEnv}" environment`); } getClientCredentials(environment) { @@ -172,21 +175,27 @@ class OAuthLogoutCommand extends BoxCommand { if (onRevokeFailure) { return onRevokeFailure; } - const result = await inquirer.prompt([ + const revokeErrorMessage = getRevokeErrorMessage(thrownError, response); + const result = await this.prompt( + [ + { + type: 'list', + name: 'action', + message: chalk`Could not revoke token: {red ${revokeErrorMessage}}\nWhat would you like to do?`, + choices: [ + { name: 'Try revoking again', value: 'retry' }, + { + name: 'Clear local session only (token remains valid on Box)', + value: 'clear', + }, + { name: 'Abort', value: 'abort' }, + ], + }, + ], { - type: 'list', - name: 'action', - message: chalk`Could not revoke token: {red ${getRevokeErrorMessage(thrownError, response)}}\nWhat would you like to do?`, - choices: [ - { name: 'Try revoking again', value: 'retry' }, - { - name: 'Clear local session only (token remains valid on Box)', - value: 'clear', - }, - { name: 'Abort', value: 'abort' }, - ], - }, - ]); + msg: `Could not revoke token: ${revokeErrorMessage}. Choose what to do next`, + } + ); return result.action; } } diff --git a/src/modules/upload.js b/src/modules/upload.js index 20e3298c..23ed0ad7 100644 --- a/src/modules/upload.js +++ b/src/modules/upload.js @@ -14,7 +14,7 @@ function createReadStream(filePath) { } } -function runChunkedUpload(uploader, size) { +function runChunkedUpload(uploader, size, onProgress) { const progressBar = new progress.Bar({ format: '[{bar}] {percentage}% | ETA: {eta_formatted} | {value}/{total} | Speed: {speed} MB/s', stopOnComplete: true, @@ -22,18 +22,28 @@ function runChunkedUpload(uploader, size) { let bytesUploaded = 0; const startTime = Date.now(); progressBar.start(size, 0, { speed: 'N/A' }); + onProgress?.(0); uploader.on('chunkUploaded', (chunk) => { bytesUploaded += chunk.part.size; progressBar.update(bytesUploaded, { speed: Math.floor(bytesUploaded / (Date.now() - startTime) / 1000), }); + onProgress?.((bytesUploaded / size) * 100); }); return uploader.start(); } +/** + * Upload a file, using a chunked upload for large files + * + * @param {BoxClient} client The Box client + * @param {Object} options Upload options + * @param {Function} [options.onProgress] Called with the percentage uploaded; only called for chunked uploads + * @returns {Promise} The upload response + */ async function uploadFile( client, - { folderID, name, stream, size, fileAttributes } + { folderID, name, stream, size, fileAttributes, onProgress } ) { if (size < CHUNKED_UPLOAD_FILE_SIZE) { return client.files.uploadFile(folderID, name, stream, fileAttributes); @@ -45,12 +55,20 @@ async function uploadFile( stream, { fileAttributes } ); - return runChunkedUpload(uploader, size); + return runChunkedUpload(uploader, size, onProgress); } +/** + * Upload a new version of a file, using a chunked upload for large files + * + * @param {BoxClient} client The Box client + * @param {Object} options Upload options + * @param {Function} [options.onProgress] Called with the percentage uploaded; only called for chunked uploads + * @returns {Promise} The upload response + */ async function uploadNewFileVersion( client, - { fileID, stream, size, fileAttributes } + { fileID, stream, size, fileAttributes, onProgress } ) { if (size < CHUNKED_UPLOAD_FILE_SIZE) { return client.files.uploadNewFileVersion( @@ -65,7 +83,7 @@ async function uploadNewFileVersion( stream, { fileAttributes } ); - return runChunkedUpload(uploader, size); + return runChunkedUpload(uploader, size, onProgress); } module.exports = { diff --git a/src/program-status.js b/src/program-status.js new file mode 100644 index 00000000..0e398245 --- /dev/null +++ b/src/program-status.js @@ -0,0 +1,346 @@ +'use strict'; + +/** + * Reports what the CLI is doing to the terminal with the Program Status + * Protocol (OSC 7501): https://www.superlogical.com/rex/docs/build/program-status + * + * Terminals that don't support the protocol ignore the sequence. Reports go to + * stderr and only when it is a TTY, so piped or redirected output stays clean. + */ + +const APP_NAME = 'box-cli'; +const OSC_PREFIX = '\u001B]7501;'; +const STRING_TERMINATOR = '\u001B\\'; +const MAX_MSG_BYTES = 2048; +const ELLIPSIS = '…'; +const KINDS = new Set(['permission', 'question', 'auth']); +const PROGRESS_STATES = new Set(['working', 'blocked']); +const SIGNALS = ['SIGINT', 'SIGTERM']; +const ENV_VARIABLE = 'BOX_PROGRAM_STATUS'; +const DISABLE_VALUES = new Set(['0', 'false', 'off', 'no']); +const ENABLE_VALUES = new Set(['1', 'true', 'on', 'yes']); + +/* eslint-disable no-control-regex */ +const ANSI_CSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/gu; +const ANSI_OSC_PATTERN = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)?/gu; +const ANSI_ESCAPE_PATTERN = /\u001B[@-Z\\-_]?/gu; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001F\u007F-\u009F]+/gu; +/* eslint-enable no-control-regex */ +const INVISIBLE_FORMATTING_PATTERN = + /[\u061C\u200B-\u200F\u202A-\u202E\u2060-\u2069\uFEFF]/gu; + +/** + * Turn arbitrary text into a single line the protocol accepts: no ANSI + * styling, control characters, or invisible text direction formatting. + * + * @param {*} text The text to sanitize + * @returns {string} The sanitized single-line text + */ +function sanitizeText(text) { + if (text === undefined || text === null) { + return ''; + } + return String(text) + .replaceAll(ANSI_OSC_PATTERN, '') + .replaceAll(ANSI_CSI_PATTERN, '') + .replaceAll(ANSI_ESCAPE_PATTERN, '') + .replaceAll(CONTROL_CHARACTER_PATTERN, ' ') + .replaceAll(INVISIBLE_FORMATTING_PATTERN, '') + .replaceAll(/\s+/gu, ' ') + .trim(); +} + +/** + * Shorten text to fit in a number of UTF-8 bytes without splitting a character + * + * @param {string} text The text to shorten + * @param {number} maxBytes The maximum encoded length in bytes + * @returns {string} The text, ending in an ellipsis if it was shortened + */ +function truncateUtf8(text, maxBytes) { + if (Buffer.byteLength(text, 'utf8') <= maxBytes) { + return text; + } + const budget = maxBytes - Buffer.byteLength(ELLIPSIS, 'utf8'); + let used = 0; + let result = ''; + for (const character of text) { + const size = Buffer.byteLength(character, 'utf8'); + if (used + size > budget) { + break; + } + used += size; + result += character; + } + return result + ELLIPSIS; +} + +/** + * Encode a free-text value (msg) as the protocol requires + * + * @param {*} text The text to encode + * @returns {string} Base64 of the sanitized UTF-8 text, or '' if nothing is left + */ +function encodeText(text) { + const sanitized = truncateUtf8(sanitizeText(text), MAX_MSG_BYTES); + return sanitized ? Buffer.from(sanitized, 'utf8').toString('base64') : ''; +} + +/** + * Build one OSC 7501 report + * + * @param {Object} record The record to report + * @param {string} record.state idle, working, done, blocked, or error + * @param {string} [record.kind] permission, question, or auth; only used with blocked + * @param {number} [record.progress] Percentage; only used with working or blocked + * @param {string} [record.msg] One human-readable line + * @returns {string} The escape sequence + */ +function formatReport({ state, kind, progress, msg }) { + const pairs = [`state=${state}`]; + if (state === 'blocked' && KINDS.has(kind)) { + pairs.push(`kind=${kind}`); + } + if (PROGRESS_STATES.has(state) && Number.isFinite(progress)) { + const percentage = Math.min(100, Math.max(0, Math.floor(progress))); + pairs.push(`progress=${percentage}`); + } + pairs.push(`app=${APP_NAME}`); + const encodedMessage = encodeText(msg); + if (encodedMessage) { + pairs.push(`msg=${encodedMessage}`); + } + return `${OSC_PREFIX}${pairs.join(':')}${STRING_TERMINATOR}`; +} + +/** + * Decide whether reports should be written + * + * @param {Object} options Inputs to the decision + * @param {Object} options.stream The stream reports would be written to + * @param {Object} options.env Environment variables + * @param {Object} [options.settings] CLI settings + * @returns {boolean} True if reports should be written + */ +function isEnabled({ stream, env, settings }) { + const override = String(env[ENV_VARIABLE] ?? '') + .trim() + .toLowerCase(); + if (DISABLE_VALUES.has(override)) { + return false; + } + if (ENABLE_VALUES.has(override)) { + return true; + } + if (settings && settings.enableProgramStatus === false) { + return false; + } + if (env.TERM === 'dumb') { + return false; + } + return Boolean(stream && stream.isTTY); +} + +/** + * Tracks and reports the status of one command run + */ +class ProgramStatus { + /** + * @param {Object} [options] Options + * @param {Object} [options.stream] Where to write reports, defaults to stderr + * @param {Object} [options.env] Environment variables, defaults to process.env + * @param {Object} [options.settings] CLI settings + * @param {boolean} [options.enabled] Force reporting on or off + * @param {string} [options.label] Human-readable name of the command + */ + constructor({ + stream = process.stderr, + env = process.env, + settings, + enabled, + label = 'box', + } = {}) { + this.stream = stream; + this.enabled = enabled ?? isEnabled({ stream, env, settings }); + this.label = label; + this._lastReport = null; + this._working = null; + this._outcome = null; + this._started = false; + this._finished = false; + this._signalHandlers = null; + } + + get started() { + return this._started; + } + + /** + * Report that the command has started working + * + * @param {string} [msg] What the command is doing + * @returns {void} + */ + start(msg = `Running ${this.label}`) { + if (!this.enabled || this._started) { + return; + } + this._started = true; + this._installSignalHandlers(); + this.working(msg); + } + + /** + * Report working, optionally with a percentage. The message and progress are + * remembered so resume() can restore them after the user answers a prompt. + * + * @param {string} [msg] What the command is doing; keeps the previous one if omitted + * @param {number} [progress] Percentage complete; indeterminate if omitted + * @returns {void} + */ + working(msg, progress) { + this._working = { + msg: msg ?? this._working?.msg, + progress, + }; + this._report({ state: 'working', ...this._working }); + } + + /** + * Report that the command is waiting on the user + * + * @param {string} kind permission, question, or auth + * @param {string} msg What the command is waiting for + * @returns {void} + */ + blocked(kind, msg) { + this._report({ state: 'blocked', kind, msg }); + } + + /** + * Return to the last working state after being blocked + * + * @returns {void} + */ + resume() { + if (this._working) { + this._report({ state: 'working', ...this._working }); + } + } + + /** + * Set the message reported when the command finishes successfully + * + * @param {string} msg What the command finished + * @returns {void} + */ + succeed(msg) { + this._outcome = { state: 'done', msg }; + } + + /** + * Mark the command as failed, for commands that report a failure without throwing + * + * @param {string} msg What failed + * @returns {void} + */ + fail(msg) { + this._outcome = { state: 'error', msg }; + } + + /** + * Mark the command as cancelled by the user + * + * @param {string} [msg] What was cancelled + * @returns {void} + */ + cancel(msg = `Cancelled ${this.label}`) { + this._outcome = { state: 'idle', msg }; + } + + /** + * Report the final state, right before the command exits + * + * @param {Error} [error] The error the command ended with, if any + * @returns {void} + */ + finish(error) { + this._removeSignalHandlers(); + if (!this._started || this._finished) { + this._finished = true; + return; + } + let outcome = this._outcome; + const failed = + error && !(error.code === 'EEXIT' && error.oclif?.exit === 0); + if (failed && outcome?.state !== 'error') { + outcome = { + state: 'error', + msg: `${this.label} failed: ${error.message}`, + }; + } + if (!outcome) { + outcome = { state: 'done', msg: `Finished ${this.label}` }; + } + this._report(outcome); + this._finished = true; + } + + _report(record) { + if (!this.enabled || this._finished) { + return; + } + this._started = true; + const report = formatReport(record); + if (report === this._lastReport) { + return; + } + this._lastReport = report; + try { + this.stream.write(report); + } catch { + // Status reports are best effort and must never break a command + } + } + + _installSignalHandlers() { + if (this._signalHandlers) { + return; + } + this._signalHandlers = new Map(); + for (const signal of SIGNALS) { + const handler = () => { + this._removeSignalHandlers(); + this._report({ state: 'idle', msg: `Cancelled ${this.label}` }); + this._finished = true; + // Only re-raise when nothing else handles the signal, so the + // default exit behavior is the same as without this handler + if (process.listenerCount(signal) === 0) { + process.kill(process.pid, signal); + } + }; + this._signalHandlers.set(signal, handler); + process.once(signal, handler); + } + } + + _removeSignalHandlers() { + if (!this._signalHandlers) { + return; + } + for (const [signal, handler] of this._signalHandlers) { + process.removeListener(signal, handler); + } + this._signalHandlers = null; + } +} + +module.exports = { + APP_NAME, + ENV_VARIABLE, + ProgramStatus, + formatReport, + isEnabled, + sanitizeText, + truncateUtf8, +}; diff --git a/test/commands/login.test.js b/test/commands/login.test.js index 1d10ff8c..6945d45b 100644 --- a/test/commands/login.test.js +++ b/test/commands/login.test.js @@ -11,6 +11,7 @@ const { getTokenInfoByAuthCode, } = require('../../src/login-helper'); const OAuthLoginCommand = require('../../src/commands/login'); +const { getProgramStatusReports } = require('../helpers/test-helper'); // Send a single callback request once the test decides flow timing. // Keeping this as a tiny helper makes callback assertions easier to read. @@ -633,6 +634,100 @@ describe('Login', function () { OAuthLoginCommand._test.resetOAuthCallbackTimeoutMs(); }); + test.do(() => { + OAuthLoginCommand._test.setOpenAuthorizeInBrowser(() => {}); + OAuthLoginCommand._test.setOAuthCallbackTimeoutMs(50); + }) + .env({ BOX_PROGRAM_STATUS: '1' }) + .stub(BoxCommand.prototype, '_loadSettings', (stub) => + stub.resolves({}) + ) + .stub(BoxCommand.prototype, 'getEnvironments', (stub) => + stub.resolves({ default: '', environments: {} }) + ) + .stub(inquirer, 'prompt', (stub) => + stub + .onFirstCall() + .resolves({ clientId: 'cid' }) + .onSecondCall() + .resolves({ clientSecret: 'secret' }) + ) + .stderr({ stripColor: false }) + .command(['login', '--platform-app', '--port', '19095']) + .it( + 'should report blocked on auth while waiting for the browser and error on timeout', + (ctx) => { + assert.deepEqual( + getProgramStatusReports(ctx.stderr).map( + ({ state, kind, msg }) => [state, kind, msg] + ), + [ + ['working', undefined, 'Running box login'], + ['blocked', 'auth', 'Enter the Client ID'], + ['working', undefined, 'Running box login'], + ['blocked', 'auth', 'Enter the Client Secret'], + ['working', undefined, 'Running box login'], + [ + 'blocked', + 'auth', + 'Waiting for you to grant access to Box in the browser', + ], + [ + 'error', + undefined, + 'Login timed out waiting for authorization', + ], + ] + ); + OAuthLoginCommand._test.resetOpenAuthorizeInBrowser(); + OAuthLoginCommand._test.resetOAuthCallbackTimeoutMs(); + } + ); + + let invalidStatePromise; + test.do(() => { + OAuthLoginCommand._test.setOpenAuthorizeInBrowser(() => {}); + OAuthLoginCommand._test.setOAuthCallbackTimeoutMs(1000); + invalidStatePromise = (async () => { + await waitForLoopbackPort(19_096); + return sendCallback( + 'http://localhost:19096/callback?state=wrong-state&code=unsolicited' + ); + })(); + }) + .env({ BOX_PROGRAM_STATUS: '1' }) + .stub(BoxCommand.prototype, '_loadSettings', (stub) => + stub.resolves({}) + ) + .stub(BoxCommand.prototype, 'getEnvironments', (stub) => + stub.resolves({ default: '', environments: {} }) + ) + .stub(inquirer, 'prompt', (stub) => + stub + .onFirstCall() + .resolves({ clientId: 'cid' }) + .onSecondCall() + .resolves({ clientSecret: 'secret' }) + ) + .stderr({ stripColor: false }) + .command(['login', '--platform-app', '--port', '19096']) + .it('should report error when the callback fails', async (ctx) => { + await invalidStatePromise; + const reports = getProgramStatusReports(ctx.stderr); + assert.deepInclude(reports, { + state: 'working', + app: 'box-cli', + msg: 'Completing sign-in to Box', + }); + assert.equal(reports.at(-1).state, 'error'); + assert.include( + reports.at(-1).msg, + 'Login failed: Invalid OAuth state received in callback' + ); + OAuthLoginCommand._test.resetOpenAuthorizeInBrowser(); + OAuthLoginCommand._test.resetOAuthCallbackTimeoutMs(); + }); + let busyServer; // Use a high random port to reduce clashes with common local services. const busyPort = 30_000 + Math.floor(Math.random() * 10_000); diff --git a/test/helpers/setup.js b/test/helpers/setup.js new file mode 100644 index 00000000..ad3dc1b8 --- /dev/null +++ b/test/helpers/setup.js @@ -0,0 +1,5 @@ +'use strict'; + +// Status reports are written to stderr when it is a TTY, which would change the +// output that tests compare against. Tests that cover reporting enable it. +process.env.BOX_PROGRAM_STATUS = '0'; diff --git a/test/helpers/test-helper.js b/test/helpers/test-helper.js index 22a2fef5..a999b6b6 100644 --- a/test/helpers/test-helper.js +++ b/test/helpers/test-helper.js @@ -63,6 +63,36 @@ function toUrlPath(filePath) { : filePath; } +// eslint-disable-next-line no-control-regex +const PROGRAM_STATUS_REPORT_PATTERN = /\u001B\]7501;([^\u001B]*)\u001B\\/gu; +const PROGRAM_STATUS_BODY_GRAMMAR = + /^[a-z]+=[A-Za-z0-9_.,+/=-]*(?::[a-z]+=[A-Za-z0-9_.,+/=-]*)*$/u; + +/** + * Parse the OSC 7501 program status reports out of captured output. Capture + * the output with stripColor disabled, since stripping removes the reports. + * + * @param {string} output Captured output + * @returns {Object[]} One record per report, with msg decoded + * @throws {Error} If a report body does not match the protocol grammar + */ +function getProgramStatusReports(output) { + return [...output.matchAll(PROGRAM_STATUS_REPORT_PATTERN)].map( + ([, body]) => { + if (!PROGRAM_STATUS_BODY_GRAMMAR.test(body)) { + throw new Error(`Malformed program status report: ${body}`); + } + const record = Object.fromEntries( + body.split(':').map((pair) => pair.split(/=(.*)/su, 2)) + ); + if (record.msg) { + record.msg = Buffer.from(record.msg, 'base64').toString('utf8'); + } + return record; + } + ); +} + module.exports = { TEST_API_ROOT, TEST_UPLOAD_ROOT, @@ -72,6 +102,7 @@ module.exports = { getBulkProgressBar, getDownloadProgressBar, getDriveLetter, + getProgramStatusReports, isWin, toUrlPath, }; diff --git a/test/program-status.test.js b/test/program-status.test.js new file mode 100644 index 00000000..9af7290e --- /dev/null +++ b/test/program-status.test.js @@ -0,0 +1,631 @@ +'use strict'; + +const { assert } = require('chai'); +const sinon = require('sinon'); +const { test } = require('@oclif/test'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const inquirer = require('inquirer'); +const BoxCommand = require('../src/box-command'); +const { + ProgramStatus, + formatReport, + isEnabled, + sanitizeText, + truncateUtf8, +} = require('../src/program-status'); +const { + TEST_API_ROOT, + getProgramStatusReports: parseReports, +} = require('./helpers/test-helper'); + +function createStream({ isTTY = true } = {}) { + const writes = []; + return { + isTTY, + writes, + write(chunk) { + writes.push(chunk); + return true; + }, + reports() { + return parseReports(writes.join('')); + }, + }; +} + +function createCommand({ yes = false, isBulk = false } = {}) { + const command = new BoxCommand([], { bin: 'box' }); + command.flags = { yes }; + command.isBulk = isBulk; + const stream = createStream(); + command._programStatus = new ProgramStatus({ + stream, + enabled: true, + label: 'box files:download', + }); + command.programStatus.working('Downloading a.txt', 30); + return { command, stream }; +} + +describe('Program status', function () { + const sandbox = sinon.createSandbox(); + + afterEach(function () { + sandbox.verifyAndRestore(); + }); + + describe('formatReport()', function () { + it('should build an OSC 7501 report terminated with ST', function () { + assert.equal( + formatReport({ state: 'working', msg: 'Uploading a.txt' }), + `\u001B]7501;state=working:app=box-cli:msg=${Buffer.from('Uploading a.txt').toString('base64')}\u001B\\` + ); + }); + + it('should only include kind for blocked reports with a known kind', function () { + assert.include( + formatReport({ state: 'blocked', kind: 'permission' }), + 'state=blocked:kind=permission:app=box-cli' + ); + assert.notInclude( + formatReport({ state: 'blocked', kind: 'unknown' }), + 'kind=' + ); + assert.notInclude( + formatReport({ state: 'done', kind: 'auth' }), + 'kind=' + ); + }); + + it('should report progress as a whole number from 0 to 100 for working and blocked', function () { + assert.include( + formatReport({ state: 'working', progress: 42.9 }), + 'progress=42:' + ); + assert.include( + formatReport({ state: 'working', progress: 150 }), + 'progress=100:' + ); + assert.include( + formatReport({ state: 'blocked', progress: -5 }), + 'progress=0:' + ); + assert.notInclude( + formatReport({ state: 'working', progress: Number.NaN }), + 'progress=' + ); + assert.notInclude( + formatReport({ state: 'done', progress: 50 }), + 'progress=' + ); + }); + + it('should leave out msg when there is no text', function () { + assert.equal( + formatReport({ state: 'done', msg: ' \n ' }), + '\u001B]7501;state=done:app=box-cli\u001B\\' + ); + }); + + it('should keep reports with very long messages under the protocol limits', function () { + const report = formatReport({ + state: 'blocked', + kind: 'question', + progress: 50, + msg: '\u{1F4C1}'.repeat(5000), + }); + const [record] = parseReports(report); + assert.isBelow(Buffer.byteLength(report), 4096); + assert.isAtMost(Buffer.byteLength(record.msg), 2048); + assert.isTrue(record.msg.endsWith('…')); + assert.notInclude(record.msg, '\uFFFD'); + }); + }); + + describe('sanitizeText()', function () { + it('should remove ANSI styling, control characters, and text direction overrides', function () { + assert.equal( + sanitizeText( + '\u001B[31mCould not revoke\u001B[39m\nWhat\tnext?\u202E\u0007' + ), + 'Could not revoke What next?' + ); + }); + + it('should remove OSC sequences', function () { + assert.equal( + sanitizeText('a\u001B]0;title\u0007b\u001B]8;;url\u001B\\c'), + 'abc' + ); + }); + }); + + describe('truncateUtf8()', function () { + it('should not change text that fits', function () { + assert.equal(truncateUtf8('héllo', 6), 'héllo'); + }); + + it('should shorten text on a character boundary and add an ellipsis', function () { + const result = truncateUtf8('ééééé', 8); + assert.equal(result, 'éé…'); + assert.isAtMost(Buffer.byteLength(result), 8); + }); + }); + + describe('isEnabled()', function () { + it('should be enabled only when the stream is a TTY by default', function () { + assert.isTrue(isEnabled({ stream: { isTTY: true }, env: {} })); + assert.isFalse(isEnabled({ stream: { isTTY: false }, env: {} })); + assert.isFalse(isEnabled({ stream: {}, env: {} })); + }); + + it('should be turned off by BOX_PROGRAM_STATUS', function () { + for (const value of ['0', 'false', 'OFF', 'no']) { + assert.isFalse( + isEnabled({ + stream: { isTTY: true }, + env: { BOX_PROGRAM_STATUS: value }, + }) + ); + } + }); + + it('should be forced on by BOX_PROGRAM_STATUS', function () { + assert.isTrue( + isEnabled({ + stream: { isTTY: false }, + env: { BOX_PROGRAM_STATUS: '1' }, + settings: { enableProgramStatus: false }, + }) + ); + }); + + it('should be turned off by the enableProgramStatus setting', function () { + assert.isFalse( + isEnabled({ + stream: { isTTY: true }, + env: {}, + settings: { enableProgramStatus: false }, + }) + ); + }); + + it('should be turned off for dumb terminals', function () { + assert.isFalse( + isEnabled({ stream: { isTTY: true }, env: { TERM: 'dumb' } }) + ); + }); + }); + + describe('ProgramStatus', function () { + let statuses = []; + + function createStatus(options = {}) { + const stream = createStream(); + const status = new ProgramStatus({ + stream, + enabled: true, + label: 'box files:upload', + ...options, + }); + statuses.push(status); + return { status, stream }; + } + + afterEach(function () { + for (const status of statuses) { + status.finish(); + } + statuses = []; + }); + + it('should report working on start and done on finish', function () { + const { status, stream } = createStatus(); + status.start(); + status.finish(); + assert.deepEqual(stream.reports(), [ + { + state: 'working', + app: 'box-cli', + msg: 'Running box files:upload', + }, + { + state: 'done', + app: 'box-cli', + msg: 'Finished box files:upload', + }, + ]); + }); + + it('should not write anything when disabled', function () { + const { status, stream } = createStatus({ enabled: false }); + status.start(); + status.blocked('auth', 'Sign in'); + status.finish(new Error('failed')); + assert.isEmpty(stream.writes); + }); + + it('should not report a final state when nothing was started', function () { + const { status, stream } = createStatus(); + status.finish(new Error('Bad flag')); + assert.isEmpty(stream.writes); + }); + + it('should skip reports identical to the previous one', function () { + const { status, stream } = createStatus(); + status.start(); + status.working('Uploading', 10.2); + status.working('Uploading', 10.8); + status.working('Uploading', 11); + assert.deepEqual( + stream.reports().map((record) => record.progress), + [undefined, '10', '11'] + ); + }); + + it('should return to the last working state after being blocked', function () { + const { status, stream } = createStatus(); + status.start(); + status.working('Bulk: 1/4 entries', 25); + status.blocked('permission', 'Overwrite file?'); + status.resume(); + assert.deepEqual(stream.reports().slice(-2), [ + { + state: 'blocked', + kind: 'permission', + app: 'box-cli', + msg: 'Overwrite file?', + }, + { + state: 'working', + progress: '25', + app: 'box-cli', + msg: 'Bulk: 1/4 entries', + }, + ]); + }); + + it('should finish with the result set by succeed()', function () { + const { status, stream } = createStatus(); + status.start(); + status.succeed('Uploaded a.txt'); + status.finish(); + assert.deepEqual(stream.reports().at(-1), { + state: 'done', + app: 'box-cli', + msg: 'Uploaded a.txt', + }); + }); + + it('should finish with error when the command throws', function () { + const { status, stream } = createStatus(); + status.start(); + status.succeed('Uploaded a.txt'); + status.finish(new Error('Network down')); + assert.deepEqual(stream.reports().at(-1), { + state: 'error', + app: 'box-cli', + msg: 'box files:upload failed: Network down', + }); + }); + + it('should keep the failure message set by fail()', function () { + const { status, stream } = createStatus(); + status.start(); + status.fail('Upload rejected'); + status.finish(new Error('raw error')); + assert.equal(stream.reports().at(-1).msg, 'Upload rejected'); + }); + + it('should finish idle when cancelled', function () { + const { status, stream } = createStatus(); + status.start(); + status.cancel(); + status.finish(); + assert.deepEqual(stream.reports().at(-1), { + state: 'idle', + app: 'box-cli', + msg: 'Cancelled box files:upload', + }); + }); + + it('should treat a successful oclif exit as done', function () { + const { status, stream } = createStatus(); + status.start(); + const exitError = Object.assign(new Error('EEXIT: 0'), { + code: 'EEXIT', + oclif: { exit: 0 }, + }); + status.finish(exitError); + assert.equal(stream.reports().at(-1).state, 'done'); + }); + + it('should not write after finishing', function () { + const { status, stream } = createStatus(); + status.start(); + status.finish(); + const count = stream.writes.length; + status.working('Late work'); + status.finish(); + assert.lengthOf(stream.writes, count); + }); + + it('should never throw when the stream fails', function () { + const status = new ProgramStatus({ + enabled: true, + stream: { + write() { + throw new Error('EPIPE'); + }, + }, + }); + assert.doesNotThrow(() => { + status.start(); + status.finish(); + }); + }); + + it('should only listen for signals while running', function () { + const sigintBefore = process.listenerCount('SIGINT'); + const sigtermBefore = process.listenerCount('SIGTERM'); + const { status } = createStatus(); + status.start(); + assert.equal(process.listenerCount('SIGINT'), sigintBefore + 1); + assert.equal(process.listenerCount('SIGTERM'), sigtermBefore + 1); + status.finish(); + assert.equal(process.listenerCount('SIGINT'), sigintBefore); + assert.equal(process.listenerCount('SIGTERM'), sigtermBefore); + }); + + it('should not listen for signals when disabled', function () { + const before = process.listenerCount('SIGINT'); + const { status } = createStatus({ enabled: false }); + status.start(); + assert.equal(process.listenerCount('SIGINT'), before); + }); + + it('should report idle when interrupted', function () { + const otherHandler = sandbox.stub(); + process.on('SIGINT', otherHandler); + const kill = sandbox.stub(process, 'kill'); + const { status, stream } = createStatus(); + try { + status.start(); + process.emit('SIGINT', 'SIGINT'); + } finally { + process.removeListener('SIGINT', otherHandler); + } + assert.deepEqual(stream.reports().at(-1), { + state: 'idle', + app: 'box-cli', + msg: 'Cancelled box files:upload', + }); + assert.isTrue(otherHandler.calledOnce); + assert.isFalse(kill.called); + status.finish(); + assert.equal(stream.reports().at(-1).state, 'idle'); + }); + + it('should re-raise the signal when nothing else handles it', function () { + const existingListeners = process.listeners('SIGTERM'); + process.removeAllListeners('SIGTERM'); + const kill = sandbox.stub(process, 'kill'); + const { status } = createStatus(); + try { + status.start(); + process.emit('SIGTERM', 'SIGTERM'); + } finally { + for (const listener of existingListeners) { + process.on('SIGTERM', listener); + } + } + assert.isTrue(kill.calledOnceWithExactly(process.pid, 'SIGTERM')); + }); + }); + + describe('BoxCommand prompts', function () { + it('should report blocked for permission while confirming, then resume working', async function () { + sandbox.stub(inquirer, 'prompt').resolves({ confirmation: true }); + const { command, stream } = createCommand(); + assert.isTrue(await command.confirm('Overwrite a.txt?')); + assert.deepEqual(stream.reports().slice(1), [ + { + state: 'blocked', + kind: 'permission', + app: 'box-cli', + msg: 'Overwrite a.txt?', + }, + { + state: 'working', + progress: '30', + app: 'box-cli', + msg: 'Downloading a.txt', + }, + ]); + }); + + it('should not report blocked when --yes skips the prompt', async function () { + const prompt = sandbox.stub(inquirer, 'prompt'); + const { command, stream } = createCommand({ yes: true }); + assert.isTrue(await command.confirm('Overwrite a.txt?')); + assert.isFalse(prompt.called); + assert.lengthOf(stream.reports(), 1); + }); + + it('should finish idle when the user declines', async function () { + sandbox.stub(inquirer, 'prompt').resolves({ confirmation: false }); + const { command, stream } = createCommand(); + await command.confirm('Overwrite a.txt?'); + command.programStatus.finish(); + assert.equal(stream.reports().at(-1).state, 'idle'); + }); + + it('should not cancel the whole bulk run when one entry is declined', async function () { + sandbox.stub(inquirer, 'prompt').resolves({ confirmation: false }); + const { command, stream } = createCommand({ isBulk: true }); + await command.confirm('Overwrite a.txt?'); + command.programStatus.finish(); + assert.equal(stream.reports().at(-1).state, 'done'); + }); + + it('should resume working when a prompt fails', async function () { + sandbox.stub(inquirer, 'prompt').rejects(new Error('closed')); + const { command, stream } = createCommand(); + let error; + try { + await command.prompt([{ name: 'answer', message: 'Name?' }], { + kind: 'question', + }); + } catch (error_) { + error = error_; + } + assert.equal(error.message, 'closed'); + assert.equal(stream.reports().at(-1).state, 'working'); + }); + + it('should use a sanitized question message when no status message is given', async function () { + sandbox.stub(inquirer, 'prompt').resolves({ answer: 'x' }); + const { command, stream } = createCommand(); + await command.prompt([ + { name: 'answer', message: '\u001B[31mPick\u001B[39m\none' }, + ]); + assert.deepEqual(stream.reports()[1], { + state: 'blocked', + kind: 'question', + app: 'box-cli', + msg: 'Pick one', + }); + }); + }); + + describe('Command runs', function () { + const bulkFilePath = path.join( + os.tmpdir(), + `box-cli-program-status-${process.pid}.json` + ); + + before(function () { + fs.writeFileSync( + bulkFilePath, + JSON.stringify([{ id: '1' }, { id: '2' }, { id: '3' }]) + ); + }); + + after(function () { + fs.rmSync(bulkFilePath, { force: true }); + }); + + test.env({ BOX_PROGRAM_STATUS: '1' }) + .nock(TEST_API_ROOT, (api) => + api.get('/2.0/users/me').reply(200, { type: 'user', id: '1' }) + ) + .stdout({ stripColor: false }) + .stderr({ stripColor: false }) + .command(['users:get', 'me', '--json', '--token=test']) + .it( + 'should report working then done on stderr and keep stdout clean', + (context) => { + assert.notInclude(context.stdout, '\u001B]7501'); + assert.deepEqual(parseReports(context.stderr), [ + { + state: 'working', + app: 'box-cli', + msg: 'Running box users:get', + }, + { + state: 'done', + app: 'box-cli', + msg: 'Finished box users:get', + }, + ]); + } + ); + + test.env({ BOX_PROGRAM_STATUS: '1' }) + .nock(TEST_API_ROOT, (api) => + api.get('/2.0/users/me').reply(404, { + type: 'error', + status: 404, + code: 'not_found', + message: 'Not Found', + request_id: 'abc', + }) + ) + .stdout({ stripColor: false }) + .stderr({ stripColor: false }) + .command(['users:get', 'me', '--token=test']) + .it('should report error when the command fails', (context) => { + const reports = parseReports(context.stderr); + assert.lengthOf(reports, 2); + assert.equal(reports[1].state, 'error'); + assert.match(reports[1].msg, /^box users:get failed: .*404/u); + }); + + test.env({ BOX_PROGRAM_STATUS: '0' }) + .nock(TEST_API_ROOT, (api) => + api.get('/2.0/users/me').reply(200, { type: 'user', id: '1' }) + ) + .stdout({ stripColor: false }) + .stderr({ stripColor: false }) + .command(['users:get', 'me', '--token=test']) + .it('should not report when turned off', (context) => { + assert.notInclude(context.stderr, '\u001B]7501'); + }); + + test.env({ BOX_PROGRAM_STATUS: '1' }) + .stdout({ stripColor: false }) + .stderr({ stripColor: false }) + .command(['users:get', '--help']) + .it('should not report for help', (context) => { + assert.notInclude(context.stderr, '\u001B]7501'); + assert.notInclude(context.stdout, '\u001B]7501'); + }); + + test.env({ BOX_PROGRAM_STATUS: '1' }) + .nock(TEST_API_ROOT, (api) => + api + .get('/2.0/users/1') + .reply(200, { type: 'user', id: '1' }) + .get('/2.0/users/2') + .reply(404, { + type: 'error', + status: 404, + code: 'not_found', + message: 'Not Found', + }) + .get('/2.0/users/3') + .reply(200, { type: 'user', id: '3' }) + ) + .stdout({ stripColor: false }) + .stderr({ stripColor: false }) + .command([ + 'users:get', + `--bulk-file-path=${bulkFilePath}`, + '--json', + '--token=test', + ]) + .it( + 'should report bulk progress and an error when an entry fails', + (context) => { + const reports = parseReports(context.stderr); + assert.deepEqual( + reports.map(({ state, progress }) => [state, progress]), + [ + ['working', undefined], + ['working', '0'], + ['working', '33'], + ['working', '66'], + ['working', '100'], + ['error', undefined], + ] + ); + assert.equal( + reports.at(-1).msg, + 'Bulk users:get: 1 of 3 entries failed' + ); + } + ); + }); +});