diff --git a/src/client/metadataTransfer.ts b/src/client/metadataTransfer.ts index 07b10c2df1..43ca29722f 100644 --- a/src/client/metadataTransfer.ts +++ b/src/client/metadataTransfer.ts @@ -111,6 +111,10 @@ export abstract class MetadataTransfer< return result; } catch (e) { const error = new MetadataTransferError('md_request_fail', e.message); + if (error.stack && e.stack) { + // append the original stack to this new error + error.stack += `\nDUE TO:\n${e.stack}`; + } if (this.event.listenerCount('error') === 0) { throw error; } diff --git a/src/convert/transformers/decomposedMetadataTransformer.ts b/src/convert/transformers/decomposedMetadataTransformer.ts index 4e3aa4c14b..ee1d4e3931 100644 --- a/src/convert/transformers/decomposedMetadataTransformer.ts +++ b/src/convert/transformers/decomposedMetadataTransformer.ts @@ -14,6 +14,7 @@ import { SourcePath, META_XML_SUFFIX, XML_NS_URL, XML_NS_KEY } from '../../commo import { ComponentSet } from '../../collections'; import { DecompositionState } from '../convertContext'; import { DecompositionStrategy } from '../../registry'; +import { TypeInferenceError } from '../../errors'; export class DecomposedMetadataTransformer extends BaseMetadataTransformer { public async toMetadataFormat(component: SourceComponent): Promise { @@ -32,16 +33,10 @@ export class DecomposedMetadataTransformer extends BaseMetadataTransformer { } else { const { fullName } = component; this.context.recomposition.setState((state) => { - if (state[fullName]) { - for (const child of component.getChildren()) { - state[fullName].children.add(child); - } - } else { - state[fullName] = { - component, - children: new ComponentSet(component.getChildren(), this.registry), - }; + if (!state[fullName]) { + state[fullName] = { component, children: new ComponentSet([], this.registry) }; } + state[fullName].children = this.ensureValidChildren(component, state[fullName].children); }); } // noop since the finalizer will push the writes to the component writer @@ -53,11 +48,9 @@ export class DecomposedMetadataTransformer extends BaseMetadataTransformer { mergeWith?: SourceComponent ): Promise { const writeInfos: WriteInfo[] = []; + const childrenOfMergeComponent = mergeWith && this.ensureValidChildren(mergeWith); const { type, fullName: parentFullName } = component; - const childrenOfMergeComponent = mergeWith - ? new ComponentSet(mergeWith.getChildren(), this.registry) - : undefined; let parentXmlObject: JsonMap; const composedMetadata = await this.getComposedMetadataEntries(component); @@ -147,6 +140,27 @@ export class DecomposedMetadataTransformer extends BaseMetadataTransformer { return writeInfos; } + // Ensures that the children of the provided SourceComponent are valid child + // types before adding them to the returned ComponentSet. Invalid child types + // can occur when projects are structured in an atypical way such as having + // ApexClasses or Layouts within a CustomObject folder. + private ensureValidChildren(component: SourceComponent, compSet?: ComponentSet): ComponentSet { + compSet = compSet || new ComponentSet([], this.registry); + const validChildTypes = Object.keys(component.type.children.types); + for (const child of component.getChildren()) { + // Ensure only valid child types are included with the parent. + if (!validChildTypes.includes(child.type?.id)) { + const filePath = child.xml || child.content; + throw new TypeInferenceError('error_unexpected_child_type', [ + filePath, + component.type.name, + ]); + } + compSet.add(child); + } + return compSet; + } + private async getComposedMetadataEntries(component: SourceComponent): Promise<[string, any][]> { const composedMetadata = (await component.parseXml())[component.type.name]; return Object.entries(composedMetadata); diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index f0f91117c0..282f7fcf72 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -30,6 +30,7 @@ export const messages = { error_convert_invalid_format: "Invalid conversion format '%s'", error_convert_not_implemented: '%s format conversion not yet implemented for type %s', error_could_not_infer_type: '%s: Could not infer a metadata type', + error_unexpected_child_type: 'Unexpected child metadata [%s] found for parent type [%s]', error_expected_source_files: "%s: Expected source files for type '%s'", error_failed_convert: 'Component conversion failed: %s', error_merge_metadata_target_unsupported: diff --git a/src/resolve/adapters/decomposedSourceAdapter.ts b/src/resolve/adapters/decomposedSourceAdapter.ts index b1fa58ae0e..e724a80283 100644 --- a/src/resolve/adapters/decomposedSourceAdapter.ts +++ b/src/resolve/adapters/decomposedSourceAdapter.ts @@ -9,7 +9,7 @@ import { SourcePath } from '../../common'; import { SourceComponent } from '../sourceComponent'; import { baseName, parentName, parseMetadataXml } from '../../utils'; import { DecompositionStrategy } from '../../registry'; -import { UnexpectedForceIgnore } from '../../errors'; +import { TypeInferenceError } from '../../errors'; /** * Handles decomposed types. A flavor of mixed content where a component can @@ -115,6 +115,11 @@ export class DecomposedSourceAdapter extends MixedContentSourceAdapter { ); } if (!triggerIsAChild) { + if (!component) { + // This is most likely metadata found within a CustomObject folder that is not a + // child type of CustomObject. E.g., Layout, SharingRules, ApexClass. + throw new TypeInferenceError('error_unexpected_child_type', [trigger, this.type.name]); + } component.content = pathToContent; } } diff --git a/test/convert/transformers/decomposedMetadataTransformer.ts b/test/convert/transformers/decomposedMetadataTransformer.ts index df745d5f97..1ae71244ea 100644 --- a/test/convert/transformers/decomposedMetadataTransformer.ts +++ b/test/convert/transformers/decomposedMetadataTransformer.ts @@ -5,16 +5,25 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { mockRegistry, mockRegistryData, decomposed } from '../../mock/registry'; +import { + mockRegistry, + mockRegistryData, + decomposed, + matchingContentFile, +} from '../../mock/registry'; import { DecomposedMetadataTransformer } from '../../../src/convert/transformers/decomposedMetadataTransformer'; import { expect } from 'chai'; import { createSandbox } from 'sinon'; import { join } from 'path'; +import { baseName } from '../../../src/utils'; import { JsToXml } from '../../../src/convert/streams'; import { DECOMPOSED_TOP_LEVEL_COMPONENT } from '../../mock/registry/type-constants/decomposedTopLevelConstants'; import { ComponentSet, SourceComponent } from '../../../src'; import { XML_NS_URL, XML_NS_KEY } from '../../../src/common'; import { ConvertContext } from '../../../src/convert/convertContext'; +import { TypeInferenceError } from '../../../src/errors'; +import { nls } from '../../../src/i18n'; +import { assert } from '@salesforce/ts-types'; const env = createSandbox(); @@ -66,6 +75,48 @@ describe('DecomposedMetadataTransformer', () => { }, }); }); + + it('should throw when an invalid child is included with the parent', async () => { + const { CONTENT_NAMES, XML_NAMES } = matchingContentFile; + const fsUnexpectedChild = [ + { + dirPath: decomposed.DECOMPOSED_PATH, + children: [ + decomposed.DECOMPOSED_CHILD_XML_NAME_1, + decomposed.DECOMPOSED_CHILD_DIR, + 'classes', + ], + }, + { + dirPath: decomposed.DECOMPOSED_CHILD_DIR_PATH, + children: [decomposed.DECOMPOSED_CHILD_XML_NAME_2], + }, + { + dirPath: join(decomposed.DECOMPOSED_PATH, 'classes'), + children: [CONTENT_NAMES[0], XML_NAMES[0]], + }, + ]; + const parentComponent = SourceComponent.createVirtualComponent( + { + name: baseName(decomposed.DECOMPOSED_XML_PATH), + type: decomposed.DECOMPOSED_COMPONENT.type, + xml: decomposed.DECOMPOSED_XML_PATH, + content: decomposed.DECOMPOSED_PATH, + }, + fsUnexpectedChild + ); + const fsPath = join(decomposed.DECOMPOSED_PATH, 'classes', XML_NAMES[0]); + const transformer = new DecomposedMetadataTransformer(mockRegistry, new ConvertContext()); + + try { + await transformer.toMetadataFormat(parentComponent); + assert(false, 'expected TypeInferenceError to be thrown'); + } catch (err) { + expect(err).to.be.instanceOf(TypeInferenceError); + const msg = nls.localize('error_unexpected_child_type', [fsPath, component.type.name]); + expect(err.message).to.equal(msg); + } + }); }); describe('toSourceFormat', () => { @@ -306,6 +357,50 @@ describe('DecomposedMetadataTransformer', () => { ]); }); + it('should throw when an invalid merge child is included with the parent', async () => { + const { CONTENT_NAMES, XML_NAMES } = matchingContentFile; + const fsUnexpectedChild = [ + { + dirPath: decomposed.DECOMPOSED_PATH, + children: [ + decomposed.DECOMPOSED_CHILD_XML_NAME_1, + decomposed.DECOMPOSED_CHILD_DIR, + 'classes', + ], + }, + { + dirPath: decomposed.DECOMPOSED_CHILD_DIR_PATH, + children: [decomposed.DECOMPOSED_CHILD_XML_NAME_2], + }, + { + dirPath: join(decomposed.DECOMPOSED_PATH, 'classes'), + children: [CONTENT_NAMES[0], XML_NAMES[0]], + }, + ]; + const parentComponent = SourceComponent.createVirtualComponent( + { + name: baseName(decomposed.DECOMPOSED_XML_PATH), + type: decomposed.DECOMPOSED_COMPONENT.type, + xml: decomposed.DECOMPOSED_XML_PATH, + content: decomposed.DECOMPOSED_PATH, + }, + fsUnexpectedChild + ); + const fsPath = join(decomposed.DECOMPOSED_PATH, 'classes', XML_NAMES[0]); + const transformer = new DecomposedMetadataTransformer(mockRegistry, new ConvertContext()); + + try { + // NOTE: it doesn't matter what the first component is for this test since it's all + // about the child components of the parentComponent. + await transformer.toSourceFormat(component, parentComponent); + assert(false, 'expected TypeInferenceError to be thrown'); + } catch (err) { + expect(err).to.be.instanceOf(TypeInferenceError); + const msg = nls.localize('error_unexpected_child_type', [fsPath, component.type.name]); + expect(err.message).to.equal(msg); + } + }); + it('should defer write operations for children that are not members of merge component', async () => { const mergeComponentChild = component.getChildren()[0]; const { fullName, type } = component; diff --git a/test/resolve/adapters/decomposedSourceAdapter.ts b/test/resolve/adapters/decomposedSourceAdapter.ts index 1598c2fb1e..bf6b23dce9 100644 --- a/test/resolve/adapters/decomposedSourceAdapter.ts +++ b/test/resolve/adapters/decomposedSourceAdapter.ts @@ -11,12 +11,15 @@ import { decomposedtoplevel, mockRegistryData, xmlInFolder, + matchingContentFile, } from '../../mock/registry'; -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { VirtualTreeContainer, SourceComponent } from '../../../src'; import { RegistryTestUtil } from '../registryTestUtil'; import { join } from 'path'; import { META_XML_SUFFIX } from '../../../src/common'; +import { TypeInferenceError } from '../../../src/errors'; +import { nls } from '../../../src/i18n'; describe('DecomposedSourceAdapter', () => { const type = mockRegistryData.types.decomposed; @@ -123,4 +126,41 @@ describe('DecomposedSourceAdapter', () => { const adapter = new DefaultSourceAdapter(type, mockRegistry); expect(adapter.getComponent(path)).to.be.undefined; }); + + it('should throw an Error when unexpected child type found in parent folder', () => { + // This is most likely an odd project structure such as metadata found within a CustomObject + // folder that is not a child type of CustomObject. E.g., Layout, SharingRules, ApexClass. + // This test adds an ApexClass to the equivalent of here: + // .../main/default/objects/MyObject/classes/MyApexClass.cls-meta.xml + // The actual ApexClass file path for the test is: + // path/to/decomposeds/a/classes/a.mcf-meta.xml + const { CONTENT_NAMES, XML_NAMES } = matchingContentFile; + const fsUnexpectedChild = [ + { + dirPath: decomposed.DECOMPOSED_PATH, + children: [ + decomposed.DECOMPOSED_CHILD_XML_NAME_1, + decomposed.DECOMPOSED_CHILD_DIR, + 'classes', + ], + }, + { + dirPath: decomposed.DECOMPOSED_CHILD_DIR_PATH, + children: [decomposed.DECOMPOSED_CHILD_XML_NAME_2], + }, + { + dirPath: join(decomposed.DECOMPOSED_PATH, 'classes'), + children: [CONTENT_NAMES[0], XML_NAMES[0]], + }, + ]; + const tree = new VirtualTreeContainer(fsUnexpectedChild); + const adapter = new DecomposedSourceAdapter(type, mockRegistry, undefined, tree); + const fsPath = join(decomposed.DECOMPOSED_PATH, 'classes', XML_NAMES[0]); + + assert.throws( + () => adapter.getComponent(fsPath, false), + TypeInferenceError, + nls.localize('error_unexpected_child_type', [fsPath, type.name]) + ); + }); });