From 014500e2387b07cfbf7b07339a98c06e81f0e2dd Mon Sep 17 00:00:00 2001 From: Shane McLaughlin Date: Wed, 29 May 2024 09:25:03 -0500 Subject: [PATCH] feat: convert project to use a new source behavior (#1015) * feat(wip): decompose command * feat: convert a project to use a new sourceBehaviorOption * chore: snapshot/schema * feat: error for behavior change with no matching types * refactor: rename the command from design * chore: ut a function * test: add a nut, fix bugs that uncovers * chore: bump sdr for renamed prop * feat: normalize paths for consistency * test: more nut for preset conversion * test: remove redundant nut * test: win ut only * refactor: calc preset dir once * test: windows ut again * test: restore full test suite * fix: edit the messages for "project convert source-behavior" (#1018) * fix: change behavior shortchar * chore: snapshot/schema * Update messages/convert.source-behavior.md Co-authored-by: Willhoit * chore: pr suggestions Co-authored-by: Willhoit --------- Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com> Co-authored-by: Eric Willhoit Co-authored-by: Willhoit --- command-snapshot.json | 8 + messages/convert.source-behavior.md | 70 ++++++ package.json | 3 +- schemas/project-convert-source__behavior.json | 31 +++ schemas/project-delete-source.json | 10 +- schemas/project-deploy-cancel.json | 8 +- schemas/project-deploy-quick.json | 8 +- schemas/project-deploy-report.json | 8 +- schemas/project-deploy-resume.json | 8 +- schemas/project-deploy-start.json | 8 +- schemas/project-deploy-validate.json | 8 +- schemas/project-retrieve-start.json | 8 +- .../project/convert/source-behavior.ts | 105 +++++++++ src/utils/convertBehavior.ts | 208 ++++++++++++++++++ test/nuts/convert/decompose.nut.ts | 135 ++++++++++++ test/utils/decomposition.test.ts | 27 +++ yarn.lock | 31 +-- 17 files changed, 622 insertions(+), 62 deletions(-) create mode 100644 messages/convert.source-behavior.md create mode 100644 schemas/project-convert-source__behavior.json create mode 100644 src/commands/project/convert/source-behavior.ts create mode 100644 src/utils/convertBehavior.ts create mode 100644 test/nuts/convert/decompose.nut.ts create mode 100644 test/utils/decomposition.test.ts diff --git a/command-snapshot.json b/command-snapshot.json index a10e03cc..69009d71 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -36,6 +36,14 @@ ], "plugin": "@salesforce/plugin-deploy-retrieve" }, + { + "alias": [], + "command": "project:convert:source-behavior", + "flagAliases": [], + "flagChars": ["b", "o"], + "flags": ["behavior", "dry-run", "flags-dir", "json", "preserve-temp-dir", "target-org"], + "plugin": "@salesforce/plugin-deploy-retrieve" + }, { "alias": ["force:source:delete"], "command": "project:delete:source", diff --git a/messages/convert.source-behavior.md b/messages/convert.source-behavior.md new file mode 100644 index 00000000..eca4154b --- /dev/null +++ b/messages/convert.source-behavior.md @@ -0,0 +1,70 @@ +# summary + +Enable a behavior of your project source files, and then update your Salesforce DX project to implement the behavior. + +# description + +Specifically, this command updates the "sourceBehaviorOption" option in the "sfdx-project.json" file and then converts the associated local source files in your project as needed. + +For example, run this command with the "--behavior decomposePermissionSetBeta" flag to start decomposing permission sets when you deploy or retrieve them. Decomposing means breaking up the monolithic metadata API format XML file that corresponds to a metadata component into smaller XML files and directories based on its subtypes. Permission sets are not decomposed by default; you must opt-in to start decomposing them by using this command. When the command finishes, your "sfdx-project.json" file is updated to always decompose permission sets, and the existing permission set files in your local package directories are converted into the new decomposed format. You run this command only once for a given behavior change. + +For more information about the possible values for the --behavior flag, see the "sourceBehaviorOptions" section in the https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_ws_config.htm topic. + +# flags.behavior.summary + +Behavior to enable; the values correspond to the possible values of the "sourceBehaviorOption" option in the "sfdx-project.json" file. + +# examples + +- Update your Salesforce DX project to decompose custom labels: + + <%= config.bin %> <%= command.id %> --behavior decomposeCustomLabelsBeta + +- Display what the command would do, but don't change any existing files: + + <%= config.bin %> <%= command.id %> --behavior decomposeCustomLabelsBeta --dry-run + +- Keep the temporary directory that contains the interim metadata API formatted files: + + <%= config.bin %> <%= command.id %> --behavior decomposeCustomLabelsBeta --dry-run --preserve-temp-dir + +# flags.dry-run.summary + +Display what the command would do, but don't make any actual changes. + +# flags.dry-run.description + +Doesn't modify the existing files in your project, including the "sfdx-project.json" file. Instead, the command lists the files that would be deleted, explains the modifications to the "sfdx-project.json" file, and outputs the resulting modifications to a new directory named `DRY-RUN-RESULTS` for review. + +# flags.preserve-temp-dir.summary + +Don't delete the metadata API format temporary directory that this command creates. Useful for debugging. + +# error.trackingNotSupported + +Your project has a default org (target-org) that uses source tracking. This operation will cause changes to the local project source files that can't be properly tracked. + +# error.trackingNotSupported.actions + +- Retrieve any changes or data you need from the org that you haven't already retrieved. +- Delete the org ("sf org delete scratch" or "sf org delete sandbox"). +- Run this command again. +- Create a new org ("sf org create scratch" or "sf org create sandbox") and deploy the modified source. + +# error.packageDirectoryNeedsMainDefault + +The package directory %s doesn't have a main/default structure. +This command moves metadata into a main/default structure, but your package directories aren't ready for it. + +# error.packageDirectoryNeedsMainDefault.actions + +- Update %s to have all its metadata inside a main/default directory structure. +- Run the command again. + +# success.dryRun + +Files were created in %s outside your package directories for you to inspect. + +# error.noTargetTypes + +The project doesn't contain any package directories with metadata that matches the specified behavior %s. diff --git a/package.json b/package.json index fa191636..0f91d773 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@salesforce/cli-plugins-testkit": "^5.3.6", "@salesforce/dev-scripts": "^9.1.2", "@salesforce/plugin-command-reference": "^3.0.86", + "@salesforce/schemas": "^1.9.0", "@salesforce/source-testkit": "^2.2.10", "@salesforce/ts-sinon": "^1.4.19", "cross-env": "^7.0.3", @@ -75,7 +76,7 @@ "description": "Commands to retrieve metadata from a Salesforce org" }, "convert": { - "description": "Commands to convert metadata from one format to another." + "description": "Commands to change the format of your project source files." }, "delete": { "description": "Commands to delete metadata from a Salesforce org." diff --git a/schemas/project-convert-source__behavior.json b/schemas/project-convert-source__behavior.json new file mode 100644 index 00000000..43727542 --- /dev/null +++ b/schemas/project-convert-source__behavior.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/SourceBehaviorResult", + "definitions": { + "SourceBehaviorResult": { + "type": "object", + "properties": { + "sourceBehaviorOptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["sourceBehaviorOptions", "deletedFiles", "createdFiles"], + "additionalProperties": false + } + } +} diff --git a/schemas/project-delete-source.json b/schemas/project-delete-source.json index daff80c4..e2fa16ca 100644 --- a/schemas/project-delete-source.json +++ b/schemas/project-delete-source.json @@ -444,6 +444,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -459,11 +460,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -492,11 +493,11 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "MetadataApiDeployStatus": { "type": "object", + "additionalProperties": false, "properties": { "id": { "type": "string" @@ -598,7 +599,6 @@ "status", "success" ], - "additionalProperties": false, "description": "Raw response returned from a checkDeployStatus call to the Metadata API" }, "CoverageResultsFileInfo": { diff --git a/schemas/project-deploy-cancel.json b/schemas/project-deploy-cancel.json index 3355e767..a0756bda 100644 --- a/schemas/project-deploy-cancel.json +++ b/schemas/project-deploy-cancel.json @@ -142,6 +142,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -157,11 +158,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -190,8 +191,7 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "RequestStatus": { "type": "string", diff --git a/schemas/project-deploy-quick.json b/schemas/project-deploy-quick.json index 3355e767..a0756bda 100644 --- a/schemas/project-deploy-quick.json +++ b/schemas/project-deploy-quick.json @@ -142,6 +142,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -157,11 +158,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -190,8 +191,7 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "RequestStatus": { "type": "string", diff --git a/schemas/project-deploy-report.json b/schemas/project-deploy-report.json index 3355e767..a0756bda 100644 --- a/schemas/project-deploy-report.json +++ b/schemas/project-deploy-report.json @@ -142,6 +142,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -157,11 +158,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -190,8 +191,7 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "RequestStatus": { "type": "string", diff --git a/schemas/project-deploy-resume.json b/schemas/project-deploy-resume.json index 3355e767..a0756bda 100644 --- a/schemas/project-deploy-resume.json +++ b/schemas/project-deploy-resume.json @@ -142,6 +142,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -157,11 +158,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -190,8 +191,7 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "RequestStatus": { "type": "string", diff --git a/schemas/project-deploy-start.json b/schemas/project-deploy-start.json index 3355e767..a0756bda 100644 --- a/schemas/project-deploy-start.json +++ b/schemas/project-deploy-start.json @@ -142,6 +142,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -157,11 +158,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -190,8 +191,7 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "RequestStatus": { "type": "string", diff --git a/schemas/project-deploy-validate.json b/schemas/project-deploy-validate.json index 3355e767..a0756bda 100644 --- a/schemas/project-deploy-validate.json +++ b/schemas/project-deploy-validate.json @@ -142,6 +142,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -157,11 +158,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -190,8 +191,7 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "RequestStatus": { "type": "string", diff --git a/schemas/project-retrieve-start.json b/schemas/project-retrieve-start.json index 465b3a45..7176d623 100644 --- a/schemas/project-retrieve-start.json +++ b/schemas/project-retrieve-start.json @@ -72,6 +72,7 @@ }, "FileResponseSuccess": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -87,11 +88,11 @@ "enum": ["Created", "Changed", "Unchanged", "Deleted"] } }, - "required": ["fullName", "state", "type"], - "additionalProperties": false + "required": ["fullName", "state", "type"] }, "FileResponseFailure": { "type": "object", + "additionalProperties": false, "properties": { "fullName": { "type": "string" @@ -120,8 +121,7 @@ "enum": ["Warning", "Error"] } }, - "required": ["error", "fullName", "problemType", "state", "type"], - "additionalProperties": false + "required": ["error", "fullName", "problemType", "state", "type"] }, "FileProperties": { "type": "object", diff --git a/src/commands/project/convert/source-behavior.ts b/src/commands/project/convert/source-behavior.ts new file mode 100644 index 00000000..3874ec80 --- /dev/null +++ b/src/commands/project/convert/source-behavior.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { rm, readFile, writeFile } from 'node:fs/promises'; +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { Messages } from '@salesforce/core'; +import { + getValidatedProjectJson, + TMP_DIR, + convertToMdapi, + DRY_RUN_DIR, + PRESETS_PROP, + PRESET_CHOICES, + getPackageDirectoriesForPreset, + convertBackToSource, +} from '../../../utils/convertBehavior.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'convert.source-behavior'); + +export type SourceBehaviorResult = { + [PRESETS_PROP]: string[]; + deletedFiles: string[]; + createdFiles: string[]; +}; + +export default class ConvertSourceBehavior extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly requiresProject = true; + + public static readonly flags = { + behavior: Flags.option({ + summary: messages.getMessage('flags.behavior.summary'), + char: 'b', + required: true, + options: PRESET_CHOICES, + })(), + 'dry-run': Flags.boolean({ + summary: messages.getMessage('flags.dry-run.summary'), + }), + 'preserve-temp-dir': Flags.boolean({ + summary: messages.getMessage('flags.preserve-temp-dir.summary'), + }), + 'target-org': Flags.optionalOrg(), + }; + + public async run(): Promise { + const { flags } = await this.parse(ConvertSourceBehavior); + if (await flags['target-org']?.supportsSourceTracking()) { + throw messages.createError('error.trackingNotSupported'); + } + const projectJson = getValidatedProjectJson(flags.behavior, this.project!); + const [backupPjsonContents, packageDirsWithDecomposable] = await Promise.all([ + flags['dry-run'] ? readFile(projectJson.getPath()) : '', + getPackageDirectoriesForPreset(this.project!, flags.behavior), + ]); + const filesToDelete = await convertToMdapi(packageDirsWithDecomposable); + + // flip the preset in the sfdx-project.json, even for dry-run, since the registry will need for conversions + projectJson.set(PRESETS_PROP, [...(projectJson.get(PRESETS_PROP) ?? []), flags.behavior]); + await projectJson.write(); + this.info(`sfdx-project.json ${PRESETS_PROP} is now [${projectJson.get(PRESETS_PROP).join(',')}]`); + + // delete the “original” files that no longer work because of project update + await Promise.all(flags['dry-run'] ? [] : filesToDelete.map((f) => rm(f))); + + const createdFiles = await convertBackToSource({ + packageDirsWithPreset: packageDirsWithDecomposable, + projectDir: this.project!.getPath(), + dryRun: flags['dry-run'], + }); + + if (!flags['preserve-temp-dir']) { + await rm(TMP_DIR, { recursive: true }); + } + + this.table( + filesToDelete.map((f) => ({ value: f })), + { value: { header: flags['dry-run'] ? 'Files that would have been deleted if not --dry-run' : 'Deleted Files' } } + ); + this.log(); + this.table( + createdFiles.map((f) => ({ value: f })), + { value: { header: 'Created Files' } } + ); + if (flags['dry-run']) { + // put it back how it was + await writeFile(projectJson.getPath(), backupPjsonContents); + this.logSuccess(messages.getMessage('success.dryRun', [DRY_RUN_DIR])); + } + + return { + createdFiles, + deletedFiles: filesToDelete, + sourceBehaviorOptions: projectJson.get(PRESETS_PROP), + }; + } +} diff --git a/src/utils/convertBehavior.ts b/src/utils/convertBehavior.ts new file mode 100644 index 00000000..e39da1e2 --- /dev/null +++ b/src/utils/convertBehavior.ts @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { existsSync, readdirSync } from 'node:fs'; +// import { platform } from 'node:os'; +import { readFile, readdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SfError, SfProject, SfProjectJson, Messages } from '@salesforce/core'; +import { + ComponentSet, + ComponentSetBuilder, + ConvertResult, + MetadataConverter, + MetadataRegistry, + RegistryAccess, + SourceComponent, +} from '@salesforce/source-deploy-retrieve'; +import { isString } from '@salesforce/ts-types'; + +export type ComponentSetAndPackageDirPath = { packageDirPath: string; cs: ComponentSet }; + +// TODO: there could be a cleaner way to read this +const PRESET_DIR = fileURLToPath( + join(import.meta.resolve('@salesforce/source-deploy-retrieve'), '..', 'registry', 'presets') +); +export const PRESETS_PROP = 'sourceBehaviorOptions'; +export const PRESET_CHOICES = (await readdir(PRESET_DIR)).map((f) => f.replace('.json', '')); +export const TMP_DIR = process.env.SF_MDAPI_TEMP_DIR ?? 'decompositionConverterTempDir'; +export const DRY_RUN_DIR = 'DRY-RUN-RESULTS'; + +/** returns packageDirectories and ComponentsSets where there is metadata of the type we'll change the behavior for */ +export const getPackageDirectoriesForPreset = async ( + project: SfProject, + preset: string +): Promise => { + const projectDir = project.getPath(); + const output = ( + await Promise.all( + project + .getPackageDirectories() + .map((pd) => pd.path) + .map(componentSetFromPackageDirectory(projectDir)(await getTypesFromPreset(preset))) + ) + ) + .filter(componentSetIsNonEmpty) + // we do this after filtering componentSets to reduce false positives (ex: dir does not have main/default but also has nothing to decompose) + .map(validateMainDefault(projectDir)); + if (output.length === 0) { + loadMessages().createError('error.noTargetTypes', [preset]); + } + return output; +}; + +/** converts the composed metadata to mdapi format in a temp dir */ +export const convertToMdapi = async (packageDirsWithDecomposable: ComponentSetAndPackageDirPath[]): Promise => + ( + await Promise.all( + packageDirsWithDecomposable.map(async (pd) => { + // convert to the mdapi targetDir + await new MetadataConverter().convert(pd.cs, 'metadata', { + type: 'directory', + outputDirectory: join(TMP_DIR, pd.packageDirPath), + genUniqueDir: false, + }); + + return getComponentSetFiles(pd.cs); + }) + ) + ) + .flat() + .map((f) => resolve(f)); + +/** get the LOCAL project json, throws if not present OR the preset already exists */ +export const getValidatedProjectJson = (preset: string, project: SfProject): SfProjectJson => { + const projectJson = project.getSfProjectJson(false); + if (projectJson.get(PRESETS_PROP)?.includes(preset)) { + throw SfError.create({ + name: 'sourceBehaviorOptionAlreadyExists', + message: `sourceBehaviorOption ${preset} already exists in sfdx-project.json`, + }); + } + return projectJson; +}; + +/** converts the temporary mdapi back to source, return a list of the created files */ +export const convertBackToSource = async ({ + packageDirsWithPreset, + projectDir, + dryRun, +}: { + packageDirsWithPreset: ComponentSetAndPackageDirPath[]; + projectDir: string; + /** if provided, will output the results into a separate directory outside the project's packageDirectories */ + dryRun: boolean; +}): Promise => [ + ...new Set( + ( + await convertToSource({ + packageDirsWithPreset, + projectDir, + dryRunDir: dryRun ? DRY_RUN_DIR : undefined, + }) + ) + .flatMap((cr) => cr.converted ?? []) + // we can't use walkContent because there's a conditional inside it + .flatMap(getSourceComponentFiles) + .filter(isString) + ), +]; + +const getSourceComponentFiles = (c: SourceComponent): string[] => + [c.xml, ...(c.content ? fullPathsFromDir(c.content) : [])].filter(isString); + +const fullPathsFromDir = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).map((d) => join(d.path, d.name)); + +/** build a component set from the original project for each pkgDir */ +const componentSetFromPackageDirectory = + (projectDir: string) => + (metadataEntries: string[]) => + async (packageDir: string): Promise => ({ + packageDirPath: packageDir, + cs: await ComponentSetBuilder.build({ + metadata: { + metadataEntries, + directoryPaths: [packageDir], + }, + projectDir, + }), + }); + +const convertToSource = async ({ + packageDirsWithPreset, + projectDir, + dryRunDir, +}: { + packageDirsWithPreset: ComponentSetAndPackageDirPath[]; + projectDir: string; + dryRunDir?: string; +}): Promise => { + // mdapi=>source convert the target dir back to the project + // it's a new converter because the project has changed and it should reload the project's registry. + const converter = new MetadataConverter(new RegistryAccess(undefined, projectDir)); + return Promise.all( + packageDirsWithPreset.map(async (pd) => + converter.convert( + // componentSet based on each mdapi folder + await ComponentSetBuilder.build({ sourcepath: [join(TMP_DIR, pd.packageDirPath)], projectDir }), + 'source', + dryRunDir + ? // dryRun outputs to a dir outside the real packageDirs folder to avoid changing real stuff + { + type: 'directory', + outputDirectory: join(projectDir, dryRunDir), + packageName: pd.packageDirPath, + genUniqueDir: false, + } + : { + type: 'merge', + mergeWith: ( + await ComponentSetBuilder.build({ + sourcepath: [pd.packageDirPath], + projectDir, + }) + ).getSourceComponents(), + defaultDirectory: join(projectDir, pd.packageDirPath), + } + ) + ) + ); +}; + +export const getTypesFromPreset = async (preset: string): Promise => + Object.values( + (JSON.parse(await readFile(join(PRESET_DIR, `${preset}.json`), 'utf-8')) as MetadataRegistry).types + ).map((t) => t.name); + +/** convert will put things in /main/default. If the packageDirs aren't configured that way, we don't want to make a mess. + * See https://salesforce.quip.com/va5IAgXmTMWF for details on that issue */ +const validateMainDefault = + (projectDir: string) => + (i: ComponentSetAndPackageDirPath): ComponentSetAndPackageDirPath => { + if (!existsSync(join(projectDir, i.packageDirPath, 'main', 'default'))) { + throw loadMessages().createError( + 'error.packageDirectoryNeedsMainDefault', + [i.packageDirPath], + [i.packageDirPath] + ); + } + return i; + }; + +const getComponentSetFiles = (cs: ComponentSet): string[] => + cs + .getSourceComponents() + .toArray() + .flatMap((c) => [c.xml, ...c.walkContent()]) + .filter(isString); + +const loadMessages = (): Messages => { + Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); + return Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'convert.source-behavior'); +}; +const componentSetIsNonEmpty = (i: ComponentSetAndPackageDirPath): boolean => i.cs.size > 0; diff --git a/test/nuts/convert/decompose.nut.ts b/test/nuts/convert/decompose.nut.ts new file mode 100644 index 00000000..d17e8d6c --- /dev/null +++ b/test/nuts/convert/decompose.nut.ts @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import { expect } from 'chai'; +import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit'; +import { type ProjectJson } from '@salesforce/schemas'; +import { SourceBehaviorResult } from '../../../src/commands/project/convert/source-behavior.js'; +import { DRY_RUN_DIR, PRESETS_PROP } from '../../../src/utils/convertBehavior.js'; + +describe('source behavior changes', () => { + let session: TestSession; + before(async () => { + session = await TestSession.create({ + devhubAuthStrategy: 'NONE', + project: { + sourceDir: path.join(process.cwd(), 'test', 'nuts', 'customLabelProject'), + }, + }); + }); + + it('produces dry run output and makes no changes', async () => { + const originalProject = await getProject(session); + const originalFileList = await fs.promises.readdir(path.join(session.project.dir, 'force-app'), { + recursive: true, + }); + + const result = execCmd( + 'project convert source-behavior --behavior decomposeCustomLabelsBeta --dry-run --json', + { + ensureExitCode: 0, + } + ); + expect(result.jsonOutput?.result.deletedFiles).to.deep.equal([ + path.join(session.project.dir, 'force-app', 'main', 'default', 'labels', 'CustomLabels.labels-meta.xml'), + ]); + expect(result.jsonOutput?.result.createdFiles).to.have.length(4); + result.jsonOutput?.result.createdFiles.map((f) => + expect(f.startsWith(path.join(session.project.dir, DRY_RUN_DIR, 'force-app', 'main', 'default'))) + ); + expect(result.jsonOutput?.result.createdFiles); + // no change because dry run + expect(await getProject(session)).to.deep.equal(originalProject); + expect(await fs.promises.readdir(path.join(session.project.dir, 'force-app'), { recursive: true })).to.deep.equal( + originalFileList + ); + // dry run dir exists + expect(fs.existsSync(path.join(session.project.dir, DRY_RUN_DIR, 'force-app', 'main'))).to.be.true; + await fs.promises.rm(path.join(session.project.dir, DRY_RUN_DIR), { recursive: true }); + }); + + it('throws on a packageDir not using main/default', async () => { + const newDir = path.join(session.project.dir, 'other-dir'); + // create the new packageDir + await fs.promises.mkdir(path.join(newDir, 'labels'), { recursive: true }); + await fs.promises.writeFile(path.join(newDir, 'labels', 'CustomLabel.labels-meta.xml'), newLabelXml); + // add the new packageDir to the project + const originalProject = await getProject(session); + + await fs.promises.writeFile( + path.join(session.project.dir, 'sfdx-project.json'), + JSON.stringify( + { + ...originalProject, + packageDirectories: [...originalProject.packageDirectories, { path: 'other-dir' }], + } satisfies ProjectJson, + null, + 2 + ) + ); + + const result = execCmd('project convert source-behavior --behavior decomposeCustomLabelsBeta --json', { + ensureExitCode: 1, + }); + expect(result.jsonOutput?.name).to.equal('PackageDirectoryNeedsMainDefaultError'); + // put stuff back the way it was + await fs.promises.rm(newDir, { recursive: true }); + await fs.promises.writeFile( + path.join(session.project.dir, 'sfdx-project.json'), + JSON.stringify(originalProject, null, 2) + ); + }); + + it('produces actual output and makes expected changes', async () => { + const result = execCmd( + 'project convert source-behavior --behavior decomposeCustomLabelsBeta --json', + { + ensureExitCode: 0, + } + ); + expect(result.jsonOutput?.result.deletedFiles).to.deep.equal([ + path.join(session.project.dir, 'force-app', 'main', 'default', 'labels', 'CustomLabels.labels-meta.xml'), + ]); + expect(result.jsonOutput?.result.createdFiles).to.have.length(4); + // it modified the project json + expect((await getProject(session))[PRESETS_PROP]).to.deep.equal(['decomposeCustomLabelsBeta']); + + // no dry run dir + expect(fs.existsSync(path.join(session.project.dir, DRY_RUN_DIR))).to.be.false; + }); + + it("throws on repeated preset that's already done", () => { + const err = execCmd( + 'project convert source-behavior --behavior decomposeCustomLabelsBeta --json', + { + ensureExitCode: 1, + } + ); + expect(err.jsonOutput?.name).to.equal('sourceBehaviorOptionAlreadyExists'); + }); + + after(async () => { + await session?.clean(); + }); +}); + +const getProject = async (session: TestSession): Promise => + JSON.parse(await fs.promises.readFile(path.join(session.project.dir, 'sfdx-project.json'), 'utf-8')) as ProjectJson; + +const newLabelXml = ` + + + More + en_US + true + DeleteMe + More + + +`; diff --git a/test/utils/decomposition.test.ts b/test/utils/decomposition.test.ts new file mode 100644 index 00000000..b20b2f38 --- /dev/null +++ b/test/utils/decomposition.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { expect, assert } from 'chai'; +import { getTypesFromPreset } from '../../src/utils/convertBehavior.js'; + +describe('source behavior changes', () => { + describe('getTypesFromPreset', () => { + // TODO: update to a long-lived preset when the beta is removed + it('returns expected type for presets with sourceBehaviorOptions', async () => { + expect(await getTypesFromPreset('decomposeCustomLabelsBeta')).to.deep.equal(['CustomLabels']); + }); + it('throws ENOENT for non-existent presets', async () => { + try { + await getTypesFromPreset('nonExistentPreset'); + } catch (e) { + assert(e instanceof Error); + assert('code' in e); + expect(e.code).to.equal('ENOENT'); + } + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 6d106100..9005c9ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7231,16 +7231,7 @@ srcset@^5.0.0: resolved "https://registry.yarnpkg.com/srcset/-/srcset-5.0.1.tgz#e660a728f195419e4afa95121099bc9efb7a1e36" integrity sha512-/P1UYbGfJVlxZag7aABNRrulEXAwCSDo7fklafOQrantuPTDmYgijJMks2zusPCVzgW9+4P69mq7w6pYuZpgxw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7300,14 +7291,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7858,7 +7842,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7876,15 +7860,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"