diff --git a/src/commands/project/deploy/quick.ts b/src/commands/project/deploy/quick.ts index 79198bab..9656f61f 100644 --- a/src/commands/project/deploy/quick.ts +++ b/src/commands/project/deploy/quick.ts @@ -147,7 +147,7 @@ export default class DeployMetadataQuick extends SfCommand { /** Resolve a job ID for a validated deploy using cache, most recent, or a job ID flag. */ const resolveJobId = (cache: DeployCache, useMostRecentFlag: boolean, jobIdFlag?: string): string => { try { - return cache.resolveLatest(useMostRecentFlag, jobIdFlag, false); + return cache.resolveLatest(useMostRecentFlag, jobIdFlag, true); } catch (e) { if (e instanceof Error && e.name === 'NoMatchingJobIdError' && jobIdFlag) { return jobIdFlag; // Use the specified 15 char job ID @@ -158,8 +158,8 @@ const resolveJobId = (cache: DeployCache, useMostRecentFlag: boolean, jobIdFlag? /** Resolve a target org using job ID in cache, or a target org flag. */ const resolveTargetOrg = async (cache: DeployCache, jobId: string, targetOrgFlag: Org): Promise => { - const aliasOrUsername = cache.get(jobId)?.['target-org']; - const targetOrg = aliasOrUsername ? await Org.create({ aliasOrUsername }) : targetOrgFlag; + const orgFromCache = cache.maybeGet(jobId)?.['target-org']; + const targetOrg = orgFromCache ? await Org.create({ aliasOrUsername: orgFromCache }) : targetOrgFlag; // If we don't have a target org at this point, throw. if (!targetOrg) { diff --git a/src/commands/project/deploy/report.ts b/src/commands/project/deploy/report.ts index d0689611..07dfc809 100644 --- a/src/commands/project/deploy/report.ts +++ b/src/commands/project/deploy/report.ts @@ -5,8 +5,6 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ - - import { Messages, Org, SfProject } from '@salesforce/core'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { ComponentSet, DeployResult, MetadataApiDeploy } from '@salesforce/source-deploy-retrieve'; @@ -17,7 +15,7 @@ import { DeployReportResultFormatter } from '../../../formatters/deployReportRes import { API, DeployResultJson } from '../../../utils/types.js'; import { coverageFormattersFlag } from '../../../utils/flags.js'; -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url) +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'deploy.metadata.report'); const deployMessages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'deploy.metadata'); const testFlags = 'Test'; @@ -75,13 +73,15 @@ export default class DeployMetadataReport extends SfCommand { const [{ flags }, cache] = await Promise.all([this.parse(DeployMetadataReport), DeployCache.create()]); const jobId = cache.resolveLatest(flags['use-most-recent'], flags['job-id'], false); - const deployOpts = cache.get(jobId) ?? {}; + const deployOpts = cache.maybeGet(jobId); const wait = flags['wait']; - const org = flags['target-org'] ?? (await Org.create({ aliasOrUsername: deployOpts['target-org'] })); + const org = deployOpts?.['target-org'] + ? await Org.create({ aliasOrUsername: deployOpts['target-org'] }) + : flags['target-org']; // if we're using mdapi we won't have a component set let componentSet = new ComponentSet(); - if (!deployOpts.isMdapi) { + if (!deployOpts?.isMdapi) { if (!cache.get(jobId)) { // If the cache file isn't there, use the project package directories for the CompSet try { @@ -102,7 +102,7 @@ export default class DeployMetadataReport extends SfCommand { id: jobId, components: componentSet, apiOptions: { - rest: deployOpts.api === API['REST'], + rest: deployOpts?.api === API['REST'], }, }); diff --git a/src/utils/deployCache.ts b/src/utils/deployCache.ts index c74863c9..db7dea0b 100644 --- a/src/utils/deployCache.ts +++ b/src/utils/deployCache.ts @@ -51,19 +51,24 @@ export class DeployCache extends TTLConfig { await cache.write(); } - public resolveLatest(useMostRecent: boolean, key: string | undefined, throwOnNotFound = true): string { - const keyFromLatest = useMostRecent ? this.getLatestKey() : key; - if (!keyFromLatest) throw cacheMessages.createError('error.NoRecentJobId'); + public resolveLatest(useMostRecent: boolean, key: string | undefined, throwOnNotFound?: boolean): string { + const resolvedKey = useMostRecent ? this.getLatestKey() : key; + if (!resolvedKey) throw cacheMessages.createError('error.NoRecentJobId'); - const jobId = this.resolveLongId(keyFromLatest); + const match = this.maybeGet(resolvedKey); - if (throwOnNotFound && !this.has(jobId)) { - throw cacheMessages.createError('error.InvalidJobId', [jobId]); + if (throwOnNotFound === true && !match) { + throw cacheMessages.createError('error.NoMatchingJobId', [resolvedKey]); } - return jobId; + return resolvedKey; } + /** + * @deprecated. Use maybeGet to handle both 15 and 18 char IDs + * returns 18-char ID unmodified, regardless of whether it's in cache or not + * returns 15-char ID if it matches a key in the cache, otherwise throws + */ public resolveLongId(jobId: string): string { if (jobId.length === 18) { return jobId; @@ -78,7 +83,27 @@ export class DeployCache extends TTLConfig { } } + /** + * + * @deprecated. Use maybeGet because the typings are wrong in sfdx-core + */ public get(jobId: string): TTLConfig.Entry { return super.get(this.resolveLongId(jobId)); } + + /** + * works with 18 and 15-character IDs. + * Prefer 18 as that's how the cache is keyed. + * Returns undefined if no match is found. + */ + public maybeGet(jobId: string): TTLConfig.Entry | undefined { + if (jobId.length === 18) { + return super.get(jobId); + } + if (jobId.length === 15) { + const match = this.keys().find((k) => k.startsWith(jobId)); + return match ? super.get(match) : undefined; + } + throw cacheMessages.createError('error.InvalidJobId', [jobId]); + } } diff --git a/test/commands/deploy/metadata/quick.nut.ts b/test/commands/deploy/metadata/quick.nut.ts index 56d2b97f..aa748f04 100644 --- a/test/commands/deploy/metadata/quick.nut.ts +++ b/test/commands/deploy/metadata/quick.nut.ts @@ -6,10 +6,13 @@ */ import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import path from 'node:path'; import { SourceTestkit } from '@salesforce/source-testkit'; import { assert, config } from 'chai'; import { execCmd } from '@salesforce/cli-plugins-testkit'; import { DeployResultJson } from '../../../../src/utils/types.js'; + config.truncateThreshold = 0; describe('deploy metadata quick NUTs', () => { @@ -81,59 +84,160 @@ describe('deploy metadata quick NUTs', () => { }); }); - describe('--job-id', () => { - it('should deploy previously validated deployment (async)', async () => { - const validation = await testkit.execute('project:deploy:validate', { - args: '--source-dir force-app', - json: true, - exitCode: 0, + describe('using cache', () => { + describe('--job-id 18', () => { + it('should deploy previously validated deployment (async)', async () => { + const validation = await testkit.execute('project:deploy:validate', { + args: '--source-dir force-app', + json: true, + exitCode: 0, + }); + assert(validation); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + + const deploy = await testkit.execute('project:deploy:quick', { + args: `--job-id ${validation.result.id}`, + json: true, + exitCode: 0, + }); + assert(deploy); + assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); }); - assert(validation); - await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); - const deploy = await testkit.execute('project:deploy:quick', { - args: `--job-id ${validation.result.id}`, - json: true, - exitCode: 0, + it('should deploy previously validated deployment (poll)', async () => { + const validation = await testkit.execute('project:deploy:validate', { + args: '--source-dir force-app', + json: true, + exitCode: 0, + }); + assert(validation); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + + const deploy = await testkit.execute('project:deploy:quick', { + args: `--job-id ${validation.result.id} --wait 20`, + json: true, + exitCode: 0, + }); + assert(deploy); + assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); }); - assert(deploy); - assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); - await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); - }); - it('should deploy previously validated deployment (poll)', async () => { - const validation = await testkit.execute('project:deploy:validate', { - args: '--source-dir force-app', - json: true, - exitCode: 0, + it('should fail to deploy previously deployed deployment', async () => { + const first = await testkit.execute('deploy:metadata', { + args: '--source-dir force-app', + json: true, + exitCode: 0, + }); + assert(first); + const deploy = await testkit.execute('project:deploy:quick', { + args: `--job-id ${first.result.id}`, + json: true, + exitCode: 1, + }); + assert(deploy); + testkit.expect.errorToHaveName(deploy, 'CannotQuickDeployError'); }); - assert(validation); - await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + }); - const deploy = await testkit.execute('project:deploy:quick', { - args: `--job-id ${validation.result.id} --wait 20`, - json: true, - exitCode: 0, + describe('--job-id 15', () => { + it('should deploy previously validated deployment (async)', async () => { + const validation = await testkit.execute('project:deploy:validate', { + args: '--source-dir force-app', + json: true, + exitCode: 0, + }); + assert(validation); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + + const deploy = await testkit.execute('project:deploy:quick', { + args: `--job-id ${validation.result.id?.slice(0, 15)}`, + json: true, + exitCode: 0, + }); + assert(deploy); + assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); }); - assert(deploy); - assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); - await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); }); + }); - it('should fail to deploy previously deployed deployment', async () => { - const first = await testkit.execute('deploy:metadata', { - args: '--source-dir force-app', - json: true, - exitCode: 0, + describe('no cache using default org', () => { + describe('--job-id 18', () => { + it('should deploy previously validated deployment (async)', async () => { + const validation = await testkit.execute('project:deploy:validate', { + args: '--source-dir force-app', + json: true, + exitCode: 0, + }); + assert(validation); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + + await fs.promises.rm(path.join(testkit.projectDir, '..', '.sf', 'deploy-cache.json'), { + recursive: true, + force: true, + }); + + const deploy = await testkit.execute('project:deploy:quick', { + args: `--job-id ${validation.result.id}`, + json: true, + exitCode: 0, + }); + assert(deploy); + assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); }); - assert(first); - const deploy = await testkit.execute('project:deploy:quick', { - args: `--job-id ${first.result.id}`, - json: true, - exitCode: 1, + + it('should deploy previously validated deployment (poll)', async () => { + const validation = await testkit.execute('project:deploy:validate', { + args: '--source-dir force-app', + json: true, + exitCode: 0, + }); + assert(validation); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + + await fs.promises.rm(path.join(testkit.projectDir, '..', '.sf', 'deploy-cache.json'), { + recursive: true, + force: true, + }); + + const deploy = await testkit.execute('project:deploy:quick', { + args: `--job-id ${validation.result.id} --wait 20`, + json: true, + exitCode: 0, + }); + assert(deploy); + assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + }); + }); + + describe('--job-id 15', () => { + it('should deploy previously validated deployment (async)', async () => { + const validation = await testkit.execute('project:deploy:validate', { + args: '--source-dir force-app', + json: true, + exitCode: 0, + }); + assert(validation); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); + + await fs.promises.rm(path.join(testkit.projectDir, '..', '.sf', 'deploy-cache.json'), { + recursive: true, + force: true, + }); + + const deploy = await testkit.execute('project:deploy:quick', { + args: `--job-id ${validation.result.id?.slice(0, 15)}`, + json: true, + exitCode: 0, + }); + assert(deploy); + assert(deploy.result.id !== validation.result.id, 'deploy result ID should not be the validation ID'); + await testkit.expect.filesToBeDeployed(['force-app/**/*'], ['force-app/test/**/*']); }); - assert(deploy); - testkit.expect.errorToHaveName(deploy, 'CannotQuickDeployError'); }); }); });