From a9bd124babc2ab853e06302627e24acd02063b1d Mon Sep 17 00:00:00 2001 From: Jake Richter Date: Thu, 11 Jun 2026 23:22:00 -0400 Subject: [PATCH] feat: add --quiet and --no-progress for token-efficient deploy/retrieve output --- command-snapshot.json | 10 ++ messages/deploy.metadata.md | 29 +++++- messages/deploy.metadata.quick.md | 12 ++- messages/deploy.metadata.resume.md | 10 +- messages/deploy.metadata.validate.md | 10 +- messages/retrieve.start.md | 16 +++ src/commands/project/deploy/quick.ts | 43 ++++++-- src/commands/project/deploy/resume.ts | 40 ++++++-- src/commands/project/deploy/start.ts | 69 +++++++++---- src/commands/project/deploy/validate.ts | 64 ++++++++---- src/commands/project/retrieve/start.ts | 47 ++++++--- src/formatters/deployResultFormatter.ts | 56 +++++++--- src/formatters/testResultsFormatter.ts | 2 + src/utils/deployStages.ts | 28 ++++- src/utils/types.ts | 6 +- test/commands/deploy/quick.test.ts | 106 +++++++++++++++++++ test/commands/deploy/resume.test.ts | 34 ++++++ test/commands/retrieve/start.test.ts | 11 ++ test/nuts/deploy/quiet.nut.ts | 118 +++++++++++++++++++++ test/utils/deployStages.test.ts | 113 ++++++++++++++++++++ test/utils/output.test.ts | 131 ++++++++++++++++++++---- 21 files changed, 840 insertions(+), 115 deletions(-) create mode 100644 test/commands/deploy/quick.test.ts create mode 100644 test/commands/deploy/resume.test.ts create mode 100644 test/nuts/deploy/quiet.nut.ts create mode 100644 test/utils/deployStages.test.ts diff --git a/command-snapshot.json b/command-snapshot.json index 7e29bd189..94f5093f4 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -114,6 +114,8 @@ "flags-dir", "job-id", "json", + "no-progress", + "quiet", "target-org", "use-most-recent", "verbose", @@ -151,6 +153,8 @@ "job-id", "json", "junit", + "no-progress", + "quiet", "results-dir", "use-most-recent", "verbose", @@ -178,9 +182,11 @@ "manifest", "metadata", "metadata-dir", + "no-progress", "post-destructive-changes", "pre-destructive-changes", "purge-on-delete", + "quiet", "results-dir", "single-package", "source-dir", @@ -209,9 +215,11 @@ "manifest", "metadata", "metadata-dir", + "no-progress", "post-destructive-changes", "pre-destructive-changes", "purge-on-delete", + "quiet", "results-dir", "single-package", "source-dir", @@ -289,8 +297,10 @@ "json", "manifest", "metadata", + "no-progress", "output-dir", "package-name", + "quiet", "single-package", "source-dir", "target-metadata-dir", diff --git a/messages/deploy.metadata.md b/messages/deploy.metadata.md index cee72524a..024503ed4 100644 --- a/messages/deploy.metadata.md +++ b/messages/deploy.metadata.md @@ -12,6 +12,17 @@ If your org allows source tracking, then this command tracks the changes in your To deploy multiple metadata components, either set multiple --metadata flags or a single --metadata flag with multiple names separated by spaces. Enclose names that contain spaces in one set of double quotes. The same syntax applies to --source-dir. +## Output modes + +| Switch | Streaming progress | Final report | +| --------------- | ------------------ | ------------------ | +| default | full | full success table | +| `--concise` | full | failures only | +| `--no-progress` | off | full success table | +| `--quiet` | off | one-line summary | + +`--quiet` builds on the same streaming-suppression primitive as `--no-progress`. For token-sensitive workflows, `--json --concise` is already a near-minimal machine-readable payload. + # examples - Deploy local changes not in the org; uses your default org: @@ -22,6 +33,14 @@ To deploy multiple metadata components, either set multiple --metadata fl <%= config.bin %> <%= command.id %> --source-dir force-app --target-org my-scratch --concise +- Deploy all source files in the "force-app" directory to an org with alias "my-scratch"; emit the trimmed JSON payload instead of the full success table: + + <%= config.bin %> <%= command.id %> --source-dir force-app --target-org my-scratch --json --concise + +- Deploy all source files in the "force-app" directory to an org with alias "my-scratch"; suppress live progress and collapse the final report to one summary line: + + <%= config.bin %> <%= command.id %> --source-dir force-app --target-org my-scratch --quiet + - Deploy all the Apex classes and custom objects that are in the "force-app" directory. The list views, layouts, etc, that are associated with the custom objects are also deployed. Both examples are equivalent: <%= config.bin %> <%= command.id %> --source-dir force-app/main/default/classes force-app/main/default/objects @@ -170,7 +189,15 @@ Show verbose output of the deploy result. # flags.concise.summary -Show concise output of the deploy result. +Show concise output of the deploy result by omitting the full success table. + +# flags.quiet.summary + +Show a one-line deploy summary and suppress live progress to minimize stdout. + +# flags.no-progress.summary + +Hide the live deploy progress stream while keeping the full final report. # flags.api-version.summary diff --git a/messages/deploy.metadata.quick.md b/messages/deploy.metadata.quick.md index e6c5f7195..4fa7cb52f 100644 --- a/messages/deploy.metadata.quick.md +++ b/messages/deploy.metadata.quick.md @@ -56,11 +56,19 @@ If the command continues to run after the wait period, the CLI returns control o # flags.verbose.summary -Show verbose output of the deploy result. +Show verbose output of the quick deploy result. # flags.concise.summary -Show concise output of the deploy result. +Show concise output of the quick deploy result by omitting the full success table. + +# flags.quiet.summary + +Show a one-line quick deploy summary. Quick deploy has no live progress stream, so this only trims the final output. + +# flags.no-progress.summary + +Accepted for parity with other deploy commands; quick deploy has no live progress stream to hide. # flags.async.summary diff --git a/messages/deploy.metadata.resume.md b/messages/deploy.metadata.resume.md index 4b417fabb..1d8756fcc 100644 --- a/messages/deploy.metadata.resume.md +++ b/messages/deploy.metadata.resume.md @@ -55,7 +55,15 @@ Show verbose output of the deploy operation result. # flags.concise.summary -Show concise output of the deploy operation result. +Show concise output of the deploy operation result by omitting the full success table. + +# flags.quiet.summary + +Show a one-line deploy resume summary and suppress live progress to minimize stdout. + +# flags.no-progress.summary + +Hide the live deploy resume progress stream while keeping the full final report. # warning.DeployNotResumable diff --git a/messages/deploy.metadata.validate.md b/messages/deploy.metadata.validate.md index 506ccfa7b..ce3fb701c 100644 --- a/messages/deploy.metadata.validate.md +++ b/messages/deploy.metadata.validate.md @@ -92,7 +92,15 @@ Show verbose output of the validation result. # flags.concise.summary -Show concise output of the validation result. +Show concise output of the validation result by omitting the full success table. + +# flags.quiet.summary + +Show a one-line validation summary and suppress live progress to minimize stdout. + +# flags.no-progress.summary + +Hide the live validation progress stream while keeping the full final report. # flags.api-version.summary diff --git a/messages/retrieve.start.md b/messages/retrieve.start.md index f03725cb1..13d8e3dbc 100644 --- a/messages/retrieve.start.md +++ b/messages/retrieve.start.md @@ -12,12 +12,20 @@ If your org allows source tracking, then this command tracks the changes in your To retrieve multiple metadata components, either use multiple --metadata flags or use a single --metadata flag with multiple names separated by spaces. Enclose names that contain spaces in one set of double quotes. The same syntax applies to --source-dir. +## Output modes + +`--quiet` suppresses the live retrieve progress stream and collapses the final output to a single summary line. `--no-progress` only hides the streaming block; it keeps the normal final output. + # examples - Retrieve all remote changes from your default org: <%= config.bin %> <%= command.id %> +- Retrieve all remote changes from your default org and keep the output to a single summary line: + + <%= config.bin %> <%= command.id %> --quiet + - Retrieve the source files in the "force-app" directory from an org with alias "my-scratch": <%= config.bin %> <%= command.id %> --source-dir force-app --target-org my-scratch @@ -177,6 +185,14 @@ Running the command multiple times with the same target adds new files and overw Directory root for the retrieved source files. +# flags.quiet.summary + +Show a one-line retrieve summary and suppress live progress to minimize stdout. + +# flags.no-progress.summary + +Hide the live retrieve progress stream while keeping the final result. + # retrieveTargetDirOverlapsPackage The retrieve target directory [%s] overlaps one of your package directories. Specify a different retrieve target directory and try again. diff --git a/src/commands/project/deploy/quick.ts b/src/commands/project/deploy/quick.ts index ac3d1d799..1dca00df9 100644 --- a/src/commands/project/deploy/quick.ts +++ b/src/commands/project/deploy/quick.ts @@ -15,7 +15,7 @@ */ import ansis from 'ansis'; -import { Messages, Org } from '@salesforce/core'; +import { EnvironmentVariable, Messages, Org } from '@salesforce/core'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; import { MetadataApiDeploy, RequestStatus } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; @@ -44,7 +44,7 @@ export default class DeployMetadataQuick extends SfCommand { }), concise: Flags.boolean({ summary: messages.getMessage('flags.concise.summary'), - exclusive: ['verbose'], + exclusive: ['verbose', 'quiet'], }), 'job-id': Flags.salesforceId({ char: 'i', @@ -63,7 +63,14 @@ export default class DeployMetadataQuick extends SfCommand { }), verbose: Flags.boolean({ summary: messages.getMessage('flags.verbose.summary'), - exclusive: ['concise'], + exclusive: ['concise', 'quiet'], + }), + quiet: Flags.boolean({ + summary: messages.getMessage('flags.quiet.summary'), + exclusive: ['verbose', 'concise'], + }), + 'no-progress': Flags.boolean({ + summary: messages.getMessage('flags.no-progress.summary'), }), wait: Flags.duration({ char: 'w', @@ -85,6 +92,13 @@ export default class DeployMetadataQuick extends SfCommand { public static errorCodes = toHelpSection('ERROR CODES', DEPLOY_STATUS_CODES_DESCRIPTIONS); + public static envVariablesSection = toHelpSection( + 'ENVIRONMENT VARIABLES', + EnvironmentVariable.SF_TARGET_ORG, + EnvironmentVariable.SF_USE_PROGRESS_BAR, + 'SF_DEPLOY_PROGRESS' + ); + private deployUrl?: string; public async run(): Promise { @@ -101,13 +115,21 @@ export default class DeployMetadataQuick extends SfCommand { id: jobId, rest: api === API['REST'], }); - this.log(`Deploy ID: ${ansis.bold(deployId)}`); this.deployUrl = buildDeployUrl(flags['target-org'], deployId); - this.log(`Deploy URL: ${ansis.bold(this.deployUrl)}`); + if (!flags.quiet) { + this.log(`Deploy ID: ${ansis.bold(deployId)}`); + this.log(`Deploy URL: ${ansis.bold(this.deployUrl)}`); + } if (flags.async) { const asyncFormatter = new AsyncDeployResultFormatter(deployId); - if (!this.jsonEnabled()) asyncFormatter.display(); + if (!this.jsonEnabled()) { + if (flags.quiet) { + this.log(`Deploy ID: ${ansis.bold(deployId)}`); + } else { + asyncFormatter.display(); + } + } return this.mixinUrlMeta(await asyncFormatter.getJson()); } @@ -122,7 +144,7 @@ export default class DeployMetadataQuick extends SfCommand { frequency: Duration.seconds(1), timeout: flags.wait, }); - const formatter = new DeployResultFormatter(result, flags); + const formatter = new DeployResultFormatter(result, { ...flags, 'target-org': targetOrg }); if (!this.jsonEnabled()) formatter.display(); @@ -130,9 +152,12 @@ export default class DeployMetadataQuick extends SfCommand { process.exitCode = determineExitCode(result); if (result.response.status === RequestStatus.Succeeded) { - this.log(); - this.logSuccess(messages.getMessage('info.QuickDeploySuccess', [deployId])); + if (!flags.quiet) { + this.log(); + this.logSuccess(messages.getMessage('info.QuickDeploySuccess', [deployId])); + } } else { + // failure detail must survive --quiet; quiet collapses successful output only this.log(messages.getMessage('error.QuickDeployFailure', [deployId, result.response.status])); } diff --git a/src/commands/project/deploy/resume.ts b/src/commands/project/deploy/resume.ts index 341fa2c08..4cbc6ede9 100644 --- a/src/commands/project/deploy/resume.ts +++ b/src/commands/project/deploy/resume.ts @@ -18,7 +18,7 @@ import { EnvironmentVariable, Messages, Org, SfError } from '@salesforce/core'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; import { DeployResult, MetadataApiDeploy } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; -import { DeployStages } from '../../../utils/deployStages.js'; +import { DeployStages, shouldShowDeployProgress } from '../../../utils/deployStages.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { API, DeployResultJson } from '../../../utils/types.js'; import { @@ -37,6 +37,13 @@ const messages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'de const testFlags = 'Test'; +export const resolveResumeVerbose = ( + flags: { quiet?: boolean; verbose?: boolean; concise?: boolean }, + deployOpts: { verbose?: boolean } + // a current --quiet/--concise request must override cached verbose; only fall back to the + // cache when no explicit verbosity flag is set on this invocation. +): boolean => (flags.quiet ?? flags.concise ? false : flags.verbose ?? deployOpts.verbose ?? false); + export default class DeployMetadataResume extends SfCommand { public static readonly description = messages.getMessage('description'); public static readonly summary = messages.getMessage('summary'); @@ -47,7 +54,7 @@ export default class DeployMetadataResume extends SfCommand { public static readonly flags = { concise: Flags.boolean({ summary: messages.getMessage('flags.concise.summary'), - exclusive: ['verbose'], + exclusive: ['verbose', 'quiet'], }), 'job-id': Flags.salesforceId({ char: 'i', @@ -65,7 +72,14 @@ export default class DeployMetadataResume extends SfCommand { }), verbose: Flags.boolean({ summary: messages.getMessage('flags.verbose.summary'), - exclusive: ['concise'], + exclusive: ['concise', 'quiet'], + }), + quiet: Flags.boolean({ + summary: messages.getMessage('flags.quiet.summary'), + exclusive: ['verbose', 'concise'], + }), + 'no-progress': Flags.boolean({ + summary: messages.getMessage('flags.no-progress.summary'), }), // we want this to allow undefined so that we can use the default value from the cache // eslint-disable-next-line sf-plugin/flag-min-max-default @@ -89,7 +103,11 @@ export default class DeployMetadataResume extends SfCommand { }), }; - public static envVariablesSection = toHelpSection('ENVIRONMENT VARIABLES', EnvironmentVariable.SF_USE_PROGRESS_BAR); + public static envVariablesSection = toHelpSection( + 'ENVIRONMENT VARIABLES', + EnvironmentVariable.SF_USE_PROGRESS_BAR, + 'SF_DEPLOY_PROGRESS' + ); public static errorCodes = toHelpSection('ERROR CODES', DEPLOY_STATUS_CODES_DESCRIPTIONS); @@ -123,6 +141,12 @@ export default class DeployMetadataResume extends SfCommand { result = new DeployResult(deployStatus, componentSet); } else { const wait = flags.wait ?? Duration.minutes(deployOpts.wait ?? 33); + const verbose = resolveResumeVerbose(flags, deployOpts); + const showProgress = shouldShowDeployProgress({ + jsonEnabled: this.jsonEnabled(), + quiet: flags.quiet, + noProgress: flags['no-progress'], + }); const { deploy } = await executeDeploy( // there will always be conflicts on a resume if anything deployed--the changes on the server are not synced to local { @@ -143,6 +167,7 @@ export default class DeployMetadataResume extends SfCommand { new DeployStages({ title: 'Resuming Deploy', jsonEnabled: this.jsonEnabled(), + showProgress, }).start( { deploy, @@ -150,7 +175,7 @@ export default class DeployMetadataResume extends SfCommand { }, { deployUrl: this.deployUrl, - verbose: flags.verbose ?? deployOpts.verbose, + verbose, } ); @@ -168,8 +193,9 @@ export default class DeployMetadataResume extends SfCommand { const formatter = new DeployResultFormatter(result, { ...flags, - verbose: deployOpts.verbose, - concise: deployOpts.concise, + verbose: resolveResumeVerbose(flags, deployOpts), + concise: flags.concise ?? deployOpts.concise, + 'target-org': org, }); if (!this.jsonEnabled()) formatter.display(); diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 56881e417..faa412a33 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -14,11 +14,12 @@ * limitations under the License. */ import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError } from '@salesforce/core'; +import ansis from 'ansis'; import { type DeployVersionData, DeployZipData } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; import { SourceConflictError } from '@salesforce/source-tracking'; -import { DeployStages } from '../../../utils/deployStages.js'; +import { DeployStages, shouldShowDeployProgress } from '../../../utils/deployStages.js'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { AsyncDeployResultJson, DeployResultJson, TestLevel } from '../../../utils/types.js'; @@ -66,7 +67,7 @@ export default class DeployMetadata extends SfCommand { }), concise: Flags.boolean({ summary: messages.getMessage('flags.concise.summary'), - exclusive: ['verbose'], + exclusive: ['verbose', 'quiet'], }), 'dry-run': Flags.boolean({ summary: messages.getMessage('flags.dry-run.summary'), @@ -140,7 +141,14 @@ export default class DeployMetadata extends SfCommand { }), verbose: Flags.boolean({ summary: messages.getMessage('flags.verbose.summary'), - exclusive: ['concise'], + exclusive: ['concise', 'quiet'], + }), + quiet: Flags.boolean({ + summary: messages.getMessage('flags.quiet.summary'), + exclusive: ['verbose', 'concise'], + }), + 'no-progress': Flags.boolean({ + summary: messages.getMessage('flags.no-progress.summary'), }), wait: Flags.duration({ char: 'w', @@ -191,7 +199,8 @@ export default class DeployMetadata extends SfCommand { public static envVariablesSection = toHelpSection( 'ENVIRONMENT VARIABLES', EnvironmentVariable.SF_TARGET_ORG, - EnvironmentVariable.SF_USE_PROGRESS_BAR + EnvironmentVariable.SF_USE_PROGRESS_BAR, + 'SF_DEPLOY_PROGRESS' ); public static errorCodes = toHelpSection('ERROR CODES', DEPLOY_STATUS_CODES_DESCRIPTIONS); @@ -229,6 +238,11 @@ export default class DeployMetadata extends SfCommand { const api = await resolveApi(this.configAggregator); const username = flags['target-org'].getUsername(); + const showProgress = shouldShowDeployProgress({ + jsonEnabled: this.jsonEnabled(), + quiet: flags.quiet, + noProgress: flags['no-progress'], + }); const title = flags['dry-run'] ? 'Deploying Metadata (dry-run)' : 'Deploying Metadata'; const lifecycle = Lifecycle.getInstance(); @@ -271,7 +285,8 @@ export default class DeployMetadata extends SfCommand { return { status: 'Nothing to deploy', files: [] }; } - if (!deploy.id) { + const deployId: string = deploy.id ?? ''; + if (!deployId) { throw new SfError('The deploy id is not available.'); } @@ -281,9 +296,10 @@ export default class DeployMetadata extends SfCommand { this.stages = new DeployStages({ title, jsonEnabled: this.jsonEnabled(), + showProgress, }); - this.deployUrl = buildDeployUrl(flags['target-org'], deploy.id); + this.deployUrl = buildDeployUrl(flags['target-org'], deployId); this.stages.start( { username, deploy }, @@ -296,17 +312,7 @@ export default class DeployMetadata extends SfCommand { } ); - if (flags.async) { - this.stages.done({ status: 'Queued', username }); - this.stages.stop(); - if (flags['coverage-formatters']) { - this.warn(messages.getMessage('asyncCoverageJunitWarning')); - } - const asyncFormatter = new AsyncDeployResultFormatter(deploy.id); - if (!this.jsonEnabled()) asyncFormatter.display(); - - return this.mixinZipMeta(await asyncFormatter.getJson()); - } + if (flags.async) return this.handleAsyncDeploy(deployId, username, flags); const result = await deploy.pollStatus({ timeout: flags.wait }); process.exitCode = determineExitCode(result); @@ -316,10 +322,10 @@ export default class DeployMetadata extends SfCommand { if (!this.jsonEnabled()) { formatter.display(); - if (flags['dry-run']) this.logSuccess('Dry-run complete.'); + if (flags['dry-run'] && !flags.quiet) this.logSuccess('Dry-run complete.'); } - await DeployCache.update(deploy.id, { status: result.response.status }); + await DeployCache.update(deployId, { status: result.response.status }); return this.mixinZipMeta(await formatter.getJson()); } @@ -351,6 +357,31 @@ export default class DeployMetadata extends SfCommand { return super.catch(error); } + private async handleAsyncDeploy( + deployId: string, + username: string | undefined, + flags: { + quiet?: boolean; + 'coverage-formatters'?: string[]; + } + ): Promise { + this.stages.done({ status: 'Queued', username }); + this.stages.stop(); + if (flags['coverage-formatters']) { + this.warn(messages.getMessage('asyncCoverageJunitWarning')); + } + const asyncFormatter = new AsyncDeployResultFormatter(deployId); + if (!this.jsonEnabled()) { + if (flags.quiet) { + this.log(`Deploy ID: ${ansis.bold(deployId)}`); + } else { + asyncFormatter.display(); + } + } + + return this.mixinZipMeta(await asyncFormatter.getJson()); + } + private mixinZipMeta(json: AsyncDeployResultJson | DeployResultJson): AsyncDeployResultJson | DeployResultJson { if (this.zipSize) { json.zipSize = this.zipSize; diff --git a/src/commands/project/deploy/validate.ts b/src/commands/project/deploy/validate.ts index bc4b65c71..aee7e7f05 100644 --- a/src/commands/project/deploy/validate.ts +++ b/src/commands/project/deploy/validate.ts @@ -20,7 +20,7 @@ import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError import { CodeCoverageWarnings, DeployVersionData, RequestStatus } from '@salesforce/source-deploy-retrieve'; import { Duration, ensureArray } from '@salesforce/kit'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; -import { DeployStages } from '../../../utils/deployStages.js'; +import { DeployStages, shouldShowDeployProgress } from '../../../utils/deployStages.js'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { DeployResultJson, TestLevel } from '../../../utils/types.js'; @@ -58,7 +58,7 @@ export default class DeployMetadataValidate extends SfCommand }), concise: Flags.boolean({ summary: messages.getMessage('flags.concise.summary'), - exclusive: ['verbose'], + exclusive: ['verbose', 'quiet'], }), manifest: Flags.file({ char: 'x', @@ -109,7 +109,14 @@ export default class DeployMetadataValidate extends SfCommand }), verbose: Flags.boolean({ summary: messages.getMessage('flags.verbose.summary'), - exclusive: ['concise'], + exclusive: ['concise', 'quiet'], + }), + quiet: Flags.boolean({ + summary: messages.getMessage('flags.quiet.summary'), + exclusive: ['verbose', 'concise'], + }), + 'no-progress': Flags.boolean({ + summary: messages.getMessage('flags.no-progress.summary'), }), wait: Flags.duration({ char: 'w', @@ -164,7 +171,8 @@ export default class DeployMetadataValidate extends SfCommand public static envVariablesSection = toHelpSection( 'ENVIRONMENT VARIABLES', EnvironmentVariable.SF_TARGET_ORG, - EnvironmentVariable.SF_USE_PROGRESS_BAR + EnvironmentVariable.SF_USE_PROGRESS_BAR, + 'SF_DEPLOY_PROGRESS' ); public static errorCodes = toHelpSection('ERROR CODES', DEPLOY_STATUS_CODES_DESCRIPTIONS); @@ -182,17 +190,19 @@ export default class DeployMetadataValidate extends SfCommand // eslint-disable-next-line @typescript-eslint/require-await Lifecycle.getInstance().on('apiVersionDeploy', async (apiData: DeployVersionData) => { - this.log( - deployMessages.getMessage('apiVersionMsgDetailed', [ - 'Validating Deployment of', - // technically manifestVersion can be undefined, but only on raw mdapi deployments. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - flags['metadata-dir'] ? '' : `v${apiData.manifestVersion}`, - username, - apiData.apiVersion, - apiData.webService, - ]) - ); + if (!flags.quiet) { + this.log( + deployMessages.getMessage('apiVersionMsgDetailed', [ + 'Validating Deployment of', + // technically manifestVersion can be undefined, but only on raw mdapi deployments. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + flags['metadata-dir'] ? '' : `v${apiData.manifestVersion}`, + username, + apiData.apiVersion, + apiData.webService, + ]) + ); + } }); const { deploy } = await executeDeploy( @@ -213,18 +223,28 @@ export default class DeployMetadataValidate extends SfCommand } this.deployUrl = buildDeployUrl(flags['target-org'], deploy.id); + const showProgress = shouldShowDeployProgress({ + jsonEnabled: this.jsonEnabled(), + quiet: flags.quiet, + noProgress: flags['no-progress'], + }); if (flags.async) { - this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); - this.log(`Deploy URL: ${ansis.bold(this.deployUrl)}`); + if (!flags.quiet) { + this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); + this.log(`Deploy URL: ${ansis.bold(this.deployUrl)}`); + } else { + this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); + } const asyncFormatter = new AsyncDeployResultFormatter(deploy.id); - if (!this.jsonEnabled()) asyncFormatter.display(); + if (!this.jsonEnabled() && !flags.quiet) asyncFormatter.display(); return this.mixinUrlMeta(await asyncFormatter.getJson()); } new DeployStages({ title: 'Validating Deployment', jsonEnabled: this.jsonEnabled(), + showProgress, }).start( { deploy, @@ -247,9 +267,11 @@ export default class DeployMetadataValidate extends SfCommand } if (result.response.status === RequestStatus.Succeeded) { - this.log(); - this.logSuccess(messages.getMessage('info.SuccessfulValidation', [deploy.id])); - this.log(messages.getMessage('info.suggestedQuickDeploy', [deploy.id])); + if (!flags.quiet) { + this.log(); + this.logSuccess(messages.getMessage('info.SuccessfulValidation', [deploy.id])); + this.log(messages.getMessage('info.suggestedQuickDeploy', [deploy.id])); + } } else { let componentDeployErrors = result.response.errorMessage; if (!result.response.errorMessage) { diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index 438abd96b..2ebed8815 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -54,6 +54,7 @@ import { getOptionalProject, getPackageDirs } from '../../../utils/project.js'; import { RetrieveResultJson } from '../../../utils/types.js'; import { writeConflictTable } from '../../../utils/conflicts.js'; import { promisesQueue } from '../../../utils/promiseQueue.js'; +import { shouldShowDeployProgress } from '../../../utils/deployStages.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'retrieve.start'); @@ -114,6 +115,12 @@ export default class RetrieveMetadata extends SfCommand { description: messages.getMessage('flags.output-dir.description'), exclusive: ['package-name', 'source-dir'], }), + quiet: Flags.boolean({ + summary: messages.getMessage('flags.quiet.summary'), + }), + 'no-progress': Flags.boolean({ + summary: messages.getMessage('flags.no-progress.summary'), + }), 'single-package': Flags.boolean({ summary: messages.getMessage('flags.single-package.summary'), dependsOn: ['target-metadata-dir'], @@ -171,7 +178,8 @@ export default class RetrieveMetadata extends SfCommand { public static envVariablesSection = toHelpSection( 'ENVIRONMENT VARIABLES', EnvironmentVariable.SF_TARGET_ORG, - EnvironmentVariable.SF_USE_PROGRESS_BAR + EnvironmentVariable.SF_USE_PROGRESS_BAR, + 'SF_DEPLOY_PROGRESS' ); protected retrieveResult!: RetrieveResult; @@ -217,6 +225,11 @@ export default class RetrieveMetadata extends SfCommand { } const format = flags['target-metadata-dir'] ? 'metadata' : 'source'; const zipFileName = flags['zip-file-name'] ?? DEFAULT_ZIP_FILE_NAME; + const showProgress = shouldShowDeployProgress({ + jsonEnabled: this.jsonEnabled(), + quiet: flags.quiet, + noProgress: flags['no-progress'], + }); const { componentSetFromNonDeletes, fileResponsesFromDelete = [] } = await buildRetrieveAndDeleteTargets( flags, @@ -240,7 +253,7 @@ export default class RetrieveMetadata extends SfCommand { }>({ stages, title: 'Retrieving Metadata', - jsonEnabled: this.jsonEnabled(), + jsonEnabled: this.jsonEnabled() || !showProgress, preStagesBlock: [ { type: 'message', @@ -321,9 +334,23 @@ export default class RetrieveMetadata extends SfCommand { flags['package-name'], fileResponsesFromDelete ); + if (format === 'metadata' && flags.unzip) { + try { + await rm(resolve(join(flags['target-metadata-dir'] ?? '', zipFileName)), { + recursive: true, + }); + } catch (e) { + // do nothing + } + } + + const result = await formatter.getJson(); + if (!this.jsonEnabled()) { - // in the case where we didn't retrieve anything, check if we have any deletes - if ( + const username = flags['target-org'].getUsername() ?? 'target org'; + if (flags.quiet && this.retrieveResult.response.status === RequestStatus.Succeeded) { + this.log(`Retrieved ${result.files.length} files from ${username}.`); + } else if ( !this.retrieveResult.response.status || this.retrieveResult.response.status === RequestStatus['Succeeded'] || fileResponsesFromDelete.length !== 0 @@ -337,17 +364,7 @@ export default class RetrieveMetadata extends SfCommand { } } - if (format === 'metadata' && flags.unzip) { - try { - await rm(resolve(join(flags['target-metadata-dir'] ?? '', zipFileName)), { - recursive: true, - }); - } catch (e) { - // do nothing - } - } - - return formatter.getJson(); + return result; } protected catch(error: Error | SfError): Promise { diff --git a/src/formatters/deployResultFormatter.ts b/src/formatters/deployResultFormatter.ts index e2cb99c93..c2def8a38 100644 --- a/src/formatters/deployResultFormatter.ts +++ b/src/formatters/deployResultFormatter.ts @@ -68,6 +68,7 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma private readonly coverageOptions: CoverageReporterOptions; private readonly resultsDir: string; private readonly junit: boolean | undefined; + private reportsWritten = false; public constructor( protected result: DeployResult, @@ -75,6 +76,7 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma 'test-level': TestLevel; verbose: boolean; concise: boolean; + quiet: boolean; 'coverage-formatters': string[]; junit: boolean; 'results-dir': string; @@ -116,14 +118,9 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma } } - if (this.coverageOptions.reportFormats?.length) { - this.createCoverageReport('no-map'); - } - if (this.junit) { - this.createJunitResults(); - } + this.writeRequestedReports(true); - if (this.verbosity === 'concise') { + if (this.verbosity === 'concise' || this.verbosity === 'quiet') { return { ...this.result.response, details: { @@ -146,7 +143,13 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma } public display(): void { - if (this.verbosity !== 'concise') { + if (this.verbosity === 'quiet' && this.result.response.status === RequestStatus.Succeeded) { + this.maybeCreateRequestedReports(true); + ux.log(this.buildQuietSummary()); + return; + } + + if (this.verbosity === 'normal' || this.verbosity === 'verbose') { this.displaySuccesses(); } this.displayFailures(); @@ -158,6 +161,7 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma public determineVerbosity(): Verbosity { if (this.flags.verbose) return 'verbose'; + if (this.flags.quiet) return 'quiet'; if (this.flags.concise) return 'concise'; return 'normal'; } @@ -183,24 +187,44 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma return failures; } - private maybeCreateRequestedReports(): void { + private maybeCreateRequestedReports(silent = false): void { + this.writeRequestedReports(silent); + } + + private writeRequestedReports(silent = false): void { + if (this.reportsWritten) return; + this.reportsWritten = true; + // only generate reports if test results are presented if (this.coverageOptions.reportFormats?.length) { - ux.log( - `Code Coverage formats, [${(this.flags['coverage-formatters'] ?? []).join(', ')}], written to ${join( - this.resultsDir, - 'coverage' - )}/` - ); + if (!silent) { + ux.log( + `Code Coverage formats, [${(this.flags['coverage-formatters'] ?? []).join(', ')}], written to ${join( + this.resultsDir, + 'coverage' + )}/` + ); + } this.createCoverageReport('no-map'); } if (this.junit) { - ux.log(`Junit results written to ${this.resultsDir}/junit/junit.xml`); + if (!silent) { + ux.log(`Junit results written to ${this.resultsDir}/junit/junit.xml`); + } this.createJunitResults(); } } + private buildQuietSummary(): string { + const verb = this.result.response.checkOnly ? 'Validated' : 'Deployed'; + const username = this.flags['target-org']?.getUsername() ?? 'target org'; + const deployId = this.result.response.id ? ` (Deploy ID ${this.result.response.id})` : ''; + const deployed = this.result.response.numberComponentsDeployed ?? 0; + const total = this.result.response.numberComponentsTotal ?? 0; + return `${verb} ${deployed}/${total} components to ${username}${deployId}.`; + } + private createJunitResults(): void { const testResult = this.transformDeployTestsResultsToTestResult(); if (testResult.summary.testsRan > 0) { diff --git a/src/formatters/testResultsFormatter.ts b/src/formatters/testResultsFormatter.ts index 1baa02a4c..0c2c4eb43 100644 --- a/src/formatters/testResultsFormatter.ts +++ b/src/formatters/testResultsFormatter.ts @@ -44,6 +44,7 @@ export class TestResultsFormatter { protected flags: Partial<{ 'test-level': TestLevel; verbose: boolean; + quiet: boolean; }>, skipVerboseTestReportOnCI = true ) { @@ -89,6 +90,7 @@ export class TestResultsFormatter { public determineVerbosity(): Verbosity { if (this.flags.verbose) return 'verbose'; + if (this.flags.quiet) return 'quiet'; return 'normal'; } } diff --git a/src/utils/deployStages.ts b/src/utils/deployStages.ts index 4b4b772fd..446bcad82 100644 --- a/src/utils/deployStages.ts +++ b/src/utils/deployStages.ts @@ -35,6 +35,13 @@ const mdTransferMessages = Messages.loadMessages('@salesforce/plugin-deploy-retr type Options = { title: string; jsonEnabled: boolean; + showProgress?: boolean; +}; + +type ShowProgressOptions = { + jsonEnabled: boolean; + quiet?: boolean; + noProgress?: boolean; }; type Data = { @@ -71,7 +78,7 @@ export class DeployStages { */ private printedApexTestFailures: Set; - public constructor({ title, jsonEnabled }: Options) { + public constructor({ title, jsonEnabled, showProgress = true }: Options) { this.printedApexTestFailures = new Set(); this.mso = new MultiStageOutput({ title, @@ -83,7 +90,11 @@ export class DeployStages { 'Updating Source Tracking', 'Done', ], - jsonEnabled, + // MultiStageOutput's `jsonEnabled` is really a "non-interactive mode" toggle: when true it + // skips rendering the live stage UI (it doesn't change serialization). We OR in `!showProgress` + // so a non-JSON suppression request (--quiet / --no-progress / SF_DEPLOY_PROGRESS / CI) reuses + // that same "don't paint the terminal" behavior. The name is the only mismatch. + jsonEnabled: jsonEnabled || !showProgress, preStagesBlock: [ { type: 'message', @@ -294,6 +305,19 @@ export class DeployStages { } } +export function showDeployProgress(): boolean { + const value = process.env.SF_DEPLOY_PROGRESS; + return value === undefined ? true : isTruthy(value); +} + +export function shouldShowDeployProgress({ jsonEnabled, quiet, noProgress }: ShowProgressOptions): boolean { + if (jsonEnabled) return false; + // Deliberately using a simple boolean check rather than nullish coalescing. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if ([quiet, noProgress].some((value) => value === true)) return false; + return showDeployProgress(); +} + function formatTestFailures(failuresData: Failures[]): string { const failures = failuresData.sort(testResultSort); diff --git a/src/utils/types.ts b/src/utils/types.ts index bf419d024..2fec2ca39 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -48,7 +48,7 @@ export type PathInfo = { path: string; }; -export type Verbosity = 'verbose' | 'concise' | 'normal'; +export type Verbosity = 'verbose' | 'concise' | 'quiet' | 'normal'; export type AsyncDeployResultJson = Omit, 'status'> & { status: RequestStatus | 'Queued' | 'Nothing to deploy'; @@ -136,5 +136,7 @@ export const isFileResponseDeleted = (fileResponse: FileResponseSuccess): boolea export const isDefined = (value?: T): value is T => value !== undefined; export function isTruthy(value: string | undefined): boolean { - return value !== '0' && value !== 'false'; + if (value === undefined) return true; + const normalized = value.toLowerCase(); + return normalized !== '0' && normalized !== 'false' && normalized !== 'no'; } diff --git a/test/commands/deploy/quick.test.ts b/test/commands/deploy/quick.test.ts new file mode 100644 index 000000000..b02467027 --- /dev/null +++ b/test/commands/deploy/quick.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import sinon from 'sinon'; +import { Org } from '@salesforce/core'; +import { RequestStatus } from '@salesforce/source-deploy-retrieve'; +import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; +import { MetadataApiDeploy } from '@salesforce/source-deploy-retrieve'; +import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; +import { stubMethod } from '@salesforce/ts-sinon'; +import { DeployResultFormatter } from '../../../src/formatters/deployResultFormatter.js'; +import { DeployCache } from '../../../src/utils/deployCache.js'; +import { getDeployResult } from '../../utils/deployResponses.js'; +import DeployQuick from '../../../src/commands/project/deploy/quick.js'; + +describe('project deploy quick', () => { + const $$ = new TestContext(); + const testOrg = new MockTestOrgData(); + testOrg.isScratchOrg = true; + testOrg.username = 'quick-test@org.com'; + + let sfCommandUxStubs: ReturnType; + let deployRecentValidationStub: sinon.SinonStub; + let deployCacheCreateStub: sinon.SinonStub; + let deployCacheUpdateStub: sinon.SinonStub; + let pollStatusStub: sinon.SinonStub; + let formatterDisplayStub: sinon.SinonStub; + let formatterGetJsonStub: sinon.SinonStub; + + const deployResult = getDeployResult('successSync'); + const formattedResult = { ...deployResult.response, files: deployResult.getFileResponses() }; + + beforeEach(async () => { + await $$.stubAuths(testOrg); + await $$.stubConfig({ 'target-org': testOrg.username }); + sfCommandUxStubs = stubSfCommandUx($$.SANDBOX); + + deployRecentValidationStub = $$.SANDBOX.stub().resolves('750000000000001AAA'); + deployCacheCreateStub = stubMethod($$.SANDBOX, DeployCache, 'create').resolves({ + resolveLatest: () => '750000000000001AAA', + maybeGet: () => undefined, + } as unknown as DeployCache); + deployCacheUpdateStub = stubMethod($$.SANDBOX, DeployCache, 'update').resolves(); + stubMethod($$.SANDBOX, Org.prototype, 'getConnection').returns({ + getAuthInfoFields: () => ({ username: testOrg.username, orgId: '00D000000000001' }), + getUsername: () => testOrg.username, + metadata: { + deployRecentValidation: deployRecentValidationStub, + }, + } as unknown as ReturnType); + pollStatusStub = stubMethod($$.SANDBOX, MetadataApiDeploy.prototype, 'pollStatus').resolves(deployResult); + formatterDisplayStub = stubMethod($$.SANDBOX, DeployResultFormatter.prototype, 'display').returns(undefined); + formatterGetJsonStub = stubMethod($$.SANDBOX, DeployResultFormatter.prototype, 'getJson').resolves(formattedResult); + }); + + afterEach(() => { + $$.restore(); + }); + + it('suppresses command-owned logs when quiet succeeds', async () => { + const result = await DeployQuick.run(['--use-most-recent', '--quiet', '--target-org', testOrg.username]); + + expect(result).to.deep.include({ + id: deployResult.response.id, + status: RequestStatus.Succeeded, + }); + expect(deployRecentValidationStub.calledOnce).to.equal(true); + expect(deployCacheCreateStub.calledOnce).to.equal(true); + expect(deployCacheUpdateStub.calledOnce).to.equal(true); + expect(pollStatusStub.calledOnce).to.equal(true); + expect(formatterDisplayStub.calledOnce).to.equal(true); + expect(formatterGetJsonStub.calledOnce).to.equal(true); + expect(sfCommandUxStubs.log.called).to.equal(false); + expect(sfCommandUxStubs.logSuccess.called).to.equal(false); + }); + + it('still surfaces the failure message when quiet and the deploy fails', async () => { + // a failed quick deploy with no component-level formatter failures must still explain itself + const failedResult = getDeployResult('successSync'); + Object.assign(failedResult.response, { status: RequestStatus.Failed }); + pollStatusStub.resolves(failedResult); + + await DeployQuick.run(['--use-most-recent', '--quiet', '--target-org', testOrg.username]); + + // quiet collapses success only — failure detail must remain + expect(sfCommandUxStubs.logSuccess.called).to.equal(false); + const failureLogged = sfCommandUxStubs.log + .getCalls() + .some((call) => call.args.some((arg) => String(arg).includes('750000000000001AAA'))); + expect(failureLogged, 'expected the QuickDeployFailure message to be logged under --quiet').to.equal(true); + }); +}); diff --git a/test/commands/deploy/resume.test.ts b/test/commands/deploy/resume.test.ts new file mode 100644 index 000000000..068f6a519 --- /dev/null +++ b/test/commands/deploy/resume.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { resolveResumeVerbose } from '../../../src/commands/project/deploy/resume.js'; + +describe('project deploy resume', () => { + it('disables cached verbose output when quiet', () => { + expect(resolveResumeVerbose({ quiet: true }, { verbose: true })).to.equal(false); + }); + + it('keeps cached verbosity when quiet is not set', () => { + expect(resolveResumeVerbose({ verbose: false }, { verbose: true })).to.equal(false); + expect(resolveResumeVerbose({}, { verbose: true })).to.equal(true); + }); + + it('lets a current --concise request override cached verbose', () => { + // a fresh `--concise` must win over a cached `verbose: true` from the original deploy + expect(resolveResumeVerbose({ concise: true }, { verbose: true })).to.equal(false); + }); +}); diff --git a/test/commands/retrieve/start.test.ts b/test/commands/retrieve/start.test.ts index 0074151ee..0fec84bdc 100644 --- a/test/commands/retrieve/start.test.ts +++ b/test/commands/retrieve/start.test.ts @@ -367,6 +367,17 @@ describe('project retrieve start', () => { expect(getJsonStub.calledOnce).to.equal(true); }); + it('should show a quiet summary with no --json', async () => { + const displayStub = $$.SANDBOX.stub(RetrieveResultFormatter.prototype, 'display'); + const getJsonStub = $$.SANDBOX.stub(RetrieveResultFormatter.prototype, 'getJson'); + getJsonStub.resolves(expectedResults); + await RetrieveMetadata.run(['--source-dir', 'somepath', '--quiet']); + expect(displayStub.calledOnce).to.equal(false); + expect(getJsonStub.calledOnce).to.equal(true); + expect(sfCommandUxStubs.log.calledOnce).to.equal(true); + expect(sfCommandUxStubs.log.firstCall.args[0]).to.include('Retrieved'); + }); + it('should NOT display output with --json', async () => { const displayStub = $$.SANDBOX.stub(RetrieveResultFormatter.prototype, 'display'); const getJsonStub = $$.SANDBOX.stub(RetrieveResultFormatter.prototype, 'getJson'); diff --git a/test/nuts/deploy/quiet.nut.ts b/test/nuts/deploy/quiet.nut.ts new file mode 100644 index 000000000..e5e6a9092 --- /dev/null +++ b/test/nuts/deploy/quiet.nut.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { join as pathJoin } from 'node:path'; +import { expect } from 'chai'; +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import { type DeployResultJson } from '../../../src/utils/types.js'; + +const APEX_DIR = 'force-app/main/default/apex'; + +describe('Deploy/Retrieve --quiet and --no-progress', () => { + let testkit: TestSession; + + before(async () => { + testkit = await TestSession.create({ + project: { gitClone: 'https://github.com/salesforcecli/sample-project-multiple-packages' }, + scratchOrgs: [{ setDefault: true, config: pathJoin('config', 'project-scratch-def.json') }], + devhubAuthStrategy: 'AUTO', + }); + }); + + after(async () => { + await testkit?.clean(); + }); + + describe('deploy start', () => { + it('baseline prints streaming progress and the full success table', () => { + const out = execCmd(`project deploy start --source-dir ${APEX_DIR}`, { + ensureExitCode: 0, + }).shellOutput.stdout; + expect(out).to.contain('Deploying Metadata'); + expect(out).to.contain('Deployed Source'); + }); + + it('--no-progress suppresses streaming but keeps the full success table', () => { + const out = execCmd(`project deploy start --source-dir ${APEX_DIR} --no-progress`, { + ensureExitCode: 0, + }).shellOutput.stdout; + expect(out, 'streaming should be suppressed').to.not.contain('Deploying Metadata'); + expect(out, 'full report should remain').to.contain('Deployed Source'); + }); + + it('SF_DEPLOY_PROGRESS=false suppresses streaming without a flag', () => { + const out = execCmd(`project deploy start --source-dir ${APEX_DIR}`, { + ensureExitCode: 0, + env: { ...process.env, SF_DEPLOY_PROGRESS: 'false' }, + }).shellOutput.stdout; + expect(out).to.not.contain('Deploying Metadata'); + expect(out).to.contain('Deployed Source'); + }); + + it('--quiet prints a one-line summary and no streaming or table', () => { + const out = execCmd(`project deploy start --source-dir ${APEX_DIR} --quiet`, { + ensureExitCode: 0, + }).shellOutput.stdout; + expect(out).to.match(/Deployed \d+\/\d+ components to .+ \(Deploy ID .+\)\./); + expect(out, 'streaming should be suppressed').to.not.contain('Deploying Metadata'); + expect(out, 'success table should be collapsed').to.not.contain('Deployed Source'); + }); + + it('--quiet composes with --json to return the trimmed (concise-equivalent) payload', () => { + const json = execCmd(`project deploy start --source-dir ${APEX_DIR} --quiet --json`, { + ensureExitCode: 0, + }).jsonOutput; + // a fully-successful deploy returns no files under the trimmed payload + expect(json?.result.files).to.deep.equal([]); + }); + + it('--quiet and --concise are mutually exclusive', () => { + const err = execCmd(`project deploy start --source-dir ${APEX_DIR} --quiet --concise`, { + ensureExitCode: 2, + }).shellOutput.stderr; + expect(err).to.match(/cannot also be provided|exclusive/i); + }); + }); + + describe('deploy validate', () => { + it('--quiet uses "Validated" wording', () => { + const out = execCmd( + `project deploy validate --source-dir ${APEX_DIR} --test-level RunLocalTests --quiet`, + { ensureExitCode: 0 } + ).shellOutput.stdout; + expect(out).to.match(/Validated \d+\/\d+ components to .+/); + expect(out).to.not.contain('Deploying Metadata'); + }); + }); + + describe('retrieve start', () => { + it('--quiet prints a one-line retrieve summary', () => { + const out = execCmd(`project retrieve start --source-dir ${APEX_DIR} --quiet`, { + ensureExitCode: 0, + }).shellOutput.stdout; + expect(out).to.match(/Retrieved \d+ files from .+\./); + }); + }); + + describe('out-of-scope guard', () => { + it('deploy preview does not accept --quiet', () => { + const err = execCmd(`project deploy preview --source-dir ${APEX_DIR} --quiet`, { + ensureExitCode: 2, + }).shellOutput.stderr; + expect(err).to.match(/Nonexistent flag|Unexpected argument|--quiet/i); + }); + }); +}); diff --git a/test/utils/deployStages.test.ts b/test/utils/deployStages.test.ts new file mode 100644 index 000000000..7d711a846 --- /dev/null +++ b/test/utils/deployStages.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { isCI, shouldShowDeployProgress, showDeployProgress } from '../../src/utils/deployStages.js'; + +describe('showDeployProgress', () => { + const original = process.env.SF_DEPLOY_PROGRESS; + + afterEach(() => { + if (original === undefined) { + delete process.env.SF_DEPLOY_PROGRESS; + } else { + process.env.SF_DEPLOY_PROGRESS = original; + } + }); + + it('defaults to on when the env var is unset', () => { + delete process.env.SF_DEPLOY_PROGRESS; + expect(showDeployProgress()).to.equal(true); + }); + + it('treats falsey strings as off', () => { + for (const value of ['false', '0', 'no']) { + process.env.SF_DEPLOY_PROGRESS = value; + expect(showDeployProgress(), value).to.equal(false); + } + }); + + it('keeps progress on for truthy strings', () => { + process.env.SF_DEPLOY_PROGRESS = 'true'; + expect(showDeployProgress()).to.equal(true); + }); +}); + +describe('shouldShowDeployProgress', () => { + const original = process.env.SF_DEPLOY_PROGRESS; + + afterEach(() => { + if (original === undefined) { + delete process.env.SF_DEPLOY_PROGRESS; + } else { + process.env.SF_DEPLOY_PROGRESS = original; + } + }); + + it('disables progress in json mode', () => { + process.env.SF_DEPLOY_PROGRESS = 'true'; + expect(shouldShowDeployProgress({ jsonEnabled: true })).to.equal(false); + }); + + it('disables progress when quiet is set', () => { + process.env.SF_DEPLOY_PROGRESS = 'true'; + expect(shouldShowDeployProgress({ jsonEnabled: false, quiet: true })).to.equal(false); + }); + + it('disables progress when no-progress is set', () => { + process.env.SF_DEPLOY_PROGRESS = 'true'; + expect(shouldShowDeployProgress({ jsonEnabled: false, noProgress: true })).to.equal(false); + }); + + it('falls back to env-driven progress when no suppressing flags are set', () => { + process.env.SF_DEPLOY_PROGRESS = 'false'; + expect(shouldShowDeployProgress({ jsonEnabled: false })).to.equal(false); + + process.env.SF_DEPLOY_PROGRESS = 'true'; + expect(shouldShowDeployProgress({ jsonEnabled: false })).to.equal(true); + }); +}); + +describe('isCI', () => { + const originalCI = process.env.CI; + const originalContinuousIntegration = process.env.CONTINUOUS_INTEGRATION; + + afterEach(() => { + if (originalCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCI; + } + + if (originalContinuousIntegration === undefined) { + delete process.env.CONTINUOUS_INTEGRATION; + } else { + process.env.CONTINUOUS_INTEGRATION = originalContinuousIntegration; + } + }); + + it('is false when CI markers are absent', () => { + delete process.env.CI; + delete process.env.CONTINUOUS_INTEGRATION; + expect(isCI()).to.equal(false); + }); + + it('is true when CI is truthy and a CI marker is present', () => { + process.env.CI = 'true'; + process.env.CONTINUOUS_INTEGRATION = 'true'; + expect(isCI()).to.equal(true); + }); +}); diff --git a/test/utils/output.test.ts b/test/utils/output.test.ts index a230bc3e7..3c96c9e76 100644 --- a/test/utils/output.test.ts +++ b/test/utils/output.test.ts @@ -24,6 +24,7 @@ import { FileResponse, ComponentStatus, } from '@salesforce/source-deploy-retrieve'; +import { Org } from '@salesforce/core'; import { Ux } from '@salesforce/sf-plugins-core'; import { getCoverageFormattersOptions } from '../../src/utils/coverage.js'; import { getZipFileSize } from '../../src/utils/output.js'; @@ -54,7 +55,11 @@ describe('deployResultFormatter', () => { const formatter = new DeployResultFormatter(deployResultFailure, { verbose: true }); formatter.display(); expect(tableStub.callCount).to.equal(1); - expect(tableStub.firstCall.args[0]).to.deep.equal({ + const failureTable = tableStub.firstCall?.args[0] as { title?: string } | undefined; + expect(failureTable).to.exist; + expect(stripVTControlCharacters(failureTable?.title ?? '')).to.equal('Component Failures [1]'); + const { title, ...tableArgs } = failureTable as { title: string }; + expect(tableArgs).to.deep.equal({ data: [ { type: 'ApexClass', @@ -69,9 +74,9 @@ describe('deployResultFormatter', () => { { key: 'error', name: 'Problem' }, { key: 'loc', name: 'Line:Column' }, ], - title: '\x1B[1m\x1B[31mComponent Failures [1]\x1B[39m\x1B[22m', overflow: 'wrap', }); + expect(stripVTControlCharacters(title)).to.equal('Component Failures [1]'); }); it('displays errors from the server not in file responses', () => { @@ -145,7 +150,11 @@ describe('deployResultFormatter', () => { }); formatter.display(); expect(tableStub.callCount).to.equal(1); - expect(tableStub.firstCall.args[0]).to.deep.equal({ + const failureTable = tableStub.firstCall?.args[0] as { title?: string } | undefined; + expect(failureTable).to.exist; + expect(stripVTControlCharacters(failureTable?.title ?? '')).to.equal('Component Failures [2]'); + const { title, ...tableArgs } = failureTable as { title: string }; + expect(tableArgs).to.deep.equal({ data: [ { type: '', @@ -167,9 +176,9 @@ describe('deployResultFormatter', () => { { key: 'error', name: 'Problem' }, { key: 'loc', name: 'Line:Column' }, ], - title: '\x1B[1m\x1B[31mComponent Failures [2]\x1B[39m\x1B[22m', overflow: 'wrap', }); + expect(stripVTControlCharacters(title)).to.equal('Component Failures [2]'); // @ts-expect-error we expect args to be strings const uxLogArgs: Array<[string]> = uxLogStub.args; expect(stripVTControlCharacters(uxLogArgs[2][0])).to.equal('Test Failures [1]'); @@ -270,8 +279,12 @@ describe('deployResultFormatter', () => { it('will warn when code coverage warning present from server', () => { const deployResult = getDeployResult('codeCoverageWarning'); const formatter = new DeployResultFormatter(deployResult, {}); + const logStub = sandbox.stub(Ux.prototype, 'log'); + const tableStub = sandbox.stub(Ux.prototype, 'table'); const warnStub = sandbox.stub(Ux.prototype, 'warn'); formatter.display(); + expect(logStub.callCount).to.be.greaterThan(0); + expect(tableStub.callCount).to.be.greaterThan(0); expect(warnStub.callCount).to.equal(1); expect(warnStub.firstCall.args[0]).to.equal( 'Average test coverage across all Apex Classes and Triggers is 25%, at least 75% test coverage is required.' @@ -309,6 +322,76 @@ describe('deployResultFormatter', () => { }); }); + describe('quiet', () => { + const deployResultSuccess = getDeployResult('successSync'); + const deployResultSuccessWithReplacements = { + ...getDeployResult('successSync'), + replacements: new Map([['foo', ['bar', 'baz']]]), + } as DeployResult; + const targetOrg = { + getUsername: () => 'my-scratch', + getConnection: () => ({ + getUsername: () => 'my-scratch', + }), + } as unknown as Org; + + let tableStub: sinon.SinonStub; + let uxLogStub: sinon.SinonStub; + + beforeEach(() => { + tableStub = sandbox.stub(Ux.prototype, 'table'); + uxLogStub = sandbox.stub(Ux.prototype, 'log'); + }); + + it('shows a single-line summary when quiet succeeds', () => { + const formatter = new DeployResultFormatter(deployResultSuccess, { quiet: true, 'target-org': targetOrg }); + formatter.display(); + expect(tableStub.called).to.be.false; + expect(uxLogStub.callCount).to.equal(1); + expect(uxLogStub.firstCall.args[0]).to.equal( + 'Deployed 1/1 components to my-scratch (Deploy ID 0Af21000011PxhqCAC).' + ); + }); + + it('uses validated wording when quiet and checkOnly succeeds', () => { + const deployResult = { + ...deployResultSuccess, + response: { + ...deployResultSuccess.response, + checkOnly: true, + }, + } as DeployResult; + const formatter = new DeployResultFormatter(deployResult, { quiet: true, 'target-org': targetOrg }); + formatter.display(); + expect(uxLogStub.callCount).to.equal(1); + expect(uxLogStub.firstCall.args[0]).to.equal( + 'Validated 1/1 components to my-scratch (Deploy ID 0Af21000011PxhqCAC).' + ); + }); + + it('falls back to failure output when quiet and the deploy fails', () => { + const deployResult = getDeployResult('failedTest'); + const formatter = new DeployResultFormatter(deployResult, { + quiet: true, + 'target-org': targetOrg, + 'test-level': TestLevel.RunAllTestsInOrg, + }); + formatter.display(); + expect(tableStub.callCount).to.equal(1); + expect(tableStub.firstCall.args[0]).to.have.property('title').that.includes('Component Failures'); + expect(uxLogStub.getCalls().some((call) => call.args.some((arg) => String(arg).includes('Test Failures [1]')))).to + .be.true; + }); + + it('returns concise-equivalent json when quiet', async () => { + const quietFormatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { quiet: true }); + const conciseFormatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { concise: true }); + const quietJson = await quietFormatter.getJson(); + const conciseJson = await conciseFormatter.getJson(); + expect(quietJson).to.deep.equal(conciseJson); + }); + }); + describe('replacements', () => { const deployResultSuccess = getDeployResult('successSync'); const deployResultSuccessWithReplacements = { @@ -330,40 +413,50 @@ describe('deployResultFormatter', () => { }); }); describe('human', () => { - let uxStub: sinon.SinonStub; + let tableStub: sinon.SinonStub; + let logStub: sinon.SinonStub; beforeEach(() => { - uxStub = sandbox.stub(process.stdout, 'write'); + tableStub = sandbox.stub(Ux.prototype, 'table'); + logStub = sandbox.stub(Ux.prototype, 'log'); }); - const getStdout = () => - uxStub - .getCalls() - // args are typed as any[] - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - .flatMap((call) => call.args) - .join('\n'); - it('shows replacements when verbose and replacements exist', () => { const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { verbose: true }); formatter.display(); - expect(getStdout()).to.include('Metadata Replacements'); - expect(getStdout()).to.include('TEXT REPLACED'); + const replacementsTableCall = tableStub.getCalls().find((call) => { + const callArg = call.args[0] as { title?: string }; + return callArg?.title && callArg.title.includes('Metadata Replacements'); + }); + expect(replacementsTableCall).to.exist; + expect(logStub.callCount).to.be.greaterThan(0); }); it('no replacements when verbose but there are none', () => { const formatter = new DeployResultFormatter(deployResultSuccess, { verbose: true }); formatter.display(); - expect(getStdout()).to.not.include('Metadata Replacements'); + const replacementsTableCall = tableStub.getCalls().find((call) => { + const callArg = call.args[0] as { title?: string }; + return callArg?.title && callArg.title.includes('Metadata Replacements'); + }); + expect(replacementsTableCall).to.not.exist; }); it('no replacements when not verbose', () => { const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { verbose: false }); formatter.display(); - expect(getStdout()).to.not.include('Metadata Replacements'); + const replacementsTableCall = tableStub.getCalls().find((call) => { + const callArg = call.args[0] as { title?: string }; + return callArg?.title && callArg.title.includes('Metadata Replacements'); + }); + expect(replacementsTableCall).to.not.exist; }); it('no replacements when concise', () => { const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { concise: true }); formatter.display(); - expect(getStdout()).to.not.include('Metadata Replacements'); + const replacementsTableCall = tableStub.getCalls().find((call) => { + const callArg = call.args[0] as { title?: string }; + return callArg?.title && callArg.title.includes('Metadata Replacements'); + }); + expect(replacementsTableCall).to.not.exist; }); }); });