Skip to content

Commit 2e45c6b

Browse files
committed
fix: ensure metadata expansion sets the XML namespace
1 parent 6de4eab commit 2e45c6b

File tree

3 files changed

+282
-3
lines changed

3 files changed

+282
-3
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { XML } from '@vlocode/util';
2+
import { MetadataRegistry } from '../metadataRegistry';
3+
import { MetadataExpander } from '../deploy/metadataExpander';
4+
5+
interface MockMetadataFile {
6+
componentType: string;
7+
componentName: string;
8+
packagePath: string;
9+
content(): Promise<Buffer>;
10+
metadata(): Promise<object | undefined>;
11+
}
12+
13+
function buildXml(rootName: string, data?: any) {
14+
return XML.stringify({
15+
[rootName]: {
16+
$: { xmlns: MetadataRegistry.xmlNamespace },
17+
...(data || {})
18+
}
19+
});
20+
}
21+
22+
describe('MetadataExpander', () => {
23+
const expander = new MetadataExpander();
24+
25+
describe('#expandMetadata - decomposed children', () => {
26+
it('CustomObject with fields should expand to child files and parent meta when parent not empty', async () => {
27+
const componentName = 'Account';
28+
const xml = buildXml('CustomObject', {
29+
label: 'Account',
30+
fields: [
31+
{
32+
fullName: 'TestField__c',
33+
label: 'Test Field',
34+
type: 'Text',
35+
length: 255
36+
}
37+
]
38+
});
39+
40+
const metadata: MockMetadataFile = {
41+
componentType: 'CustomObject',
42+
componentName,
43+
packagePath: `objects/${componentName}.object`,
44+
content: async () => Buffer.from(xml),
45+
metadata: async () => undefined
46+
};
47+
48+
const result = await expander.expandMetadata(metadata);
49+
const files = Object.keys(result).sort();
50+
51+
expect(files).toContain(`${componentName}/fields/TestField__c.field-meta.xml`);
52+
expect(files).toContain(`${componentName}/${componentName}.object-meta.xml`);
53+
54+
// Validate child XML root and content
55+
const childXml = result[`${componentName}/fields/TestField__c.field-meta.xml`].toString('utf8');
56+
expect(XML.getRootTagName(childXml)).toBe('CustomField');
57+
const childParsed = XML.parse(childXml);
58+
expect(childParsed.CustomField.fullName).toBe('TestField__c');
59+
60+
// Validate parent XML root
61+
const parentXml = result[`${componentName}/${componentName}.object-meta.xml`].toString('utf8');
62+
expect(XML.getRootTagName(parentXml)).toBe('CustomObject');
63+
const parentParsed = XML.parse(parentXml);
64+
expect(parentParsed.CustomObject.label).toBe('Account');
65+
});
66+
});
67+
68+
describe('#expandMetadata - StaticResource', () => {
69+
it('should use mime extension and write content + -meta.xml', async () => {
70+
const componentName = 'MyLogo';
71+
const metaObj = { contentType: 'image/png', cacheControl: 'Public' };
72+
const metadata: MockMetadataFile = {
73+
componentType: 'StaticResource',
74+
componentName,
75+
packagePath: `staticresources/${componentName}.resource`,
76+
content: async () => Buffer.from('PNG_DATA'),
77+
metadata: async () => metaObj
78+
};
79+
80+
const result = await expander.expandMetadata(metadata);
81+
const files = Object.keys(result).sort();
82+
83+
expect(files).toContain(`${componentName}.png`);
84+
expect(files).toContain(`${componentName}.png-meta.xml`);
85+
expect(result[`${componentName}.png`].toString()).toBe('PNG_DATA');
86+
87+
const metaXml = result[`${componentName}.png-meta.xml`].toString('utf8');
88+
expect(XML.getRootTagName(metaXml)).toBe('StaticResource');
89+
const parsed = XML.parse(metaXml);
90+
expect(parsed.StaticResource.contentType).toBe('image/png');
91+
});
92+
93+
it('should return empty when metadata() is undefined', async () => {
94+
const componentName = 'NoMeta';
95+
const metadata: MockMetadataFile = {
96+
componentType: 'StaticResource',
97+
componentName,
98+
packagePath: `staticresources/${componentName}.resource`,
99+
content: async () => Buffer.from('DATA'),
100+
metadata: async () => undefined
101+
};
102+
103+
const result = await expander.expandMetadata(metadata);
104+
expect(Object.keys(result)).toHaveLength(0);
105+
});
106+
});
107+
108+
describe('#expandMetadata - CustomLabels', () => {
109+
it('should expand each <labels> entry into its own child file and not include parent when empty', async () => {
110+
const componentName = 'CustomLabels';
111+
const xml = buildXml('CustomLabels', {
112+
labels: [
113+
{
114+
fullName: 'Welcome',
115+
language: 'en_US',
116+
value: 'Hello',
117+
protected: false
118+
},
119+
{
120+
fullName: 'Farewell',
121+
language: 'en_US',
122+
value: 'Goodbye',
123+
protected: false
124+
}
125+
]
126+
});
127+
128+
const metadata: MockMetadataFile = {
129+
componentType: 'CustomLabels',
130+
componentName,
131+
packagePath: `labels/${componentName}.labels`,
132+
content: async () => Buffer.from(xml),
133+
metadata: async () => undefined
134+
};
135+
136+
const result = await expander.expandMetadata(metadata);
137+
const files = Object.keys(result).sort();
138+
139+
// Expect child files for each label
140+
expect(files).toContain(`${componentName}/labels/Welcome.labels-meta.xml`);
141+
expect(files).toContain(`${componentName}/labels/Farewell.labels-meta.xml`);
142+
143+
// Parent should not be included because after removing <labels> the parent is empty
144+
expect(files.some(f => f.endsWith(`${componentName}.labels-meta.xml`))).toBe(false);
145+
146+
// Validate child XML
147+
const childXml = result[`${componentName}/labels/Welcome.labels-meta.xml`].toString('utf8');
148+
expect(XML.getRootTagName(childXml)).toBe('CustomLabel');
149+
const parsed = XML.parse(childXml);
150+
expect(parsed.CustomLabel.fullName).toBe('Welcome');
151+
expect(parsed.CustomLabel.value).toBe('Hello');
152+
});
153+
});
154+
155+
describe('#expandMetadata - CustomObjectTranslation', () => {
156+
it('should expand field translations into child files and omit parent when empty', async () => {
157+
const componentName = 'Account-en_US';
158+
const xml = buildXml('CustomObjectTranslation', {
159+
fields: [
160+
{
161+
name: 'Field__c',
162+
label: 'Field Translated Label'
163+
}
164+
]
165+
});
166+
167+
const metadata: MockMetadataFile = {
168+
componentType: 'CustomObjectTranslation',
169+
componentName,
170+
packagePath: `objectTranslations/${componentName}.objectTranslation`,
171+
content: async () => Buffer.from(xml),
172+
metadata: async () => undefined
173+
};
174+
175+
const result = await expander.expandMetadata(metadata);
176+
const files = Object.keys(result).sort();
177+
178+
expect(files).toContain(`${componentName}/fields/Field__c.fieldTranslation-meta.xml`);
179+
expect(files.some(f => f.endsWith(`${componentName}.objectTranslation-meta.xml`))).toBe(false);
180+
181+
const childXml = result[`${componentName}/fields/Field__c.fieldTranslation-meta.xml`].toString('utf8');
182+
expect(XML.getRootTagName(childXml)).toBe('CustomFieldTranslation');
183+
const parsed = XML.parse(childXml);
184+
expect(parsed.CustomFieldTranslation.name).toBe('Field__c');
185+
expect(parsed.CustomFieldTranslation.label).toBe('Field Translated Label');
186+
});
187+
});
188+
189+
describe('#expandMetadata - generic content', () => {
190+
it('should append -meta.xml when body looks like XML and file is not .xml', async () => {
191+
const metadata: MockMetadataFile = {
192+
componentType: 'UnknownType',
193+
componentName: 'Cmp',
194+
packagePath: 'src/Cmp.cmp',
195+
content: async () => Buffer.from('<?xml version="1.0"?><aura:component />'),
196+
metadata: async () => undefined
197+
};
198+
199+
const result = await expander.expandMetadata(metadata);
200+
expect(Object.keys(result)).toEqual(['Cmp.cmp-meta.xml']);
201+
});
202+
203+
it('should keep original name when body is not XML', async () => {
204+
const metadata: MockMetadataFile = {
205+
componentType: 'UnknownType',
206+
componentName: 'File',
207+
packagePath: 'src/file.txt',
208+
content: async () => Buffer.from('hello'),
209+
metadata: async () => undefined
210+
};
211+
212+
const result = await expander.expandMetadata(metadata);
213+
expect(Object.keys(result)).toEqual(['file.txt']);
214+
});
215+
216+
it('should keep original .xml filename (no -meta.xml) when extension is .xml', async () => {
217+
const metadata: MockMetadataFile = {
218+
componentType: 'UnknownType',
219+
componentName: 'Meta',
220+
packagePath: 'src/meta.xml',
221+
content: async () => Buffer.from('<?xml version="1.0"?><root/>'),
222+
metadata: async () => undefined
223+
};
224+
225+
const result = await expander.expandMetadata(metadata);
226+
expect(Object.keys(result)).toEqual(['meta.xml']);
227+
});
228+
});
229+
230+
describe('#saveExpandedMetadata', () => {
231+
it('should write files to provided destination using outputFile callback', async () => {
232+
const componentName = 'Account';
233+
const xml = buildXml('CustomObject', {
234+
label: 'Account',
235+
fields: [ { fullName: 'Field__c' } ]
236+
});
237+
const metadata: MockMetadataFile = {
238+
componentType: 'CustomObject',
239+
componentName,
240+
packagePath: `objects/${componentName}.object`,
241+
content: async () => Buffer.from(xml),
242+
metadata: async () => undefined
243+
};
244+
245+
const writes: Array<{ path: string; data: Buffer }> = [];
246+
const outputFile = async (name: string, data: Buffer) => {
247+
writes.push({ path: name, data });
248+
};
249+
250+
await expander.saveExpandedMetadata(metadata, 'c:/tmp/out', { outputFile });
251+
252+
// Verify at least two writes (child + parent)
253+
expect(writes.length).toBeGreaterThanOrEqual(2);
254+
const paths = writes.map(w => w.path.replace(/\\/g, '/')).sort();
255+
expect(paths).toContain('c:/tmp/out/Account/fields/Field__c.field-meta.xml');
256+
expect(paths).toContain('c:/tmp/out/Account/Account.object-meta.xml');
257+
});
258+
});
259+
});

packages/salesforce/src/deploy/metadataExpander.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class MetadataExpander {
2424
const type = MetadataRegistry.getMetadataType(metadata.componentType);
2525

2626
if (type?.childXmlNames.length) {
27-
return this.expandMetadataChildren(metadata.componentName, type, XML.parse(content));
27+
return this.expandMetadataChildren(metadata.componentName, type, content);
2828
}
2929

3030
if (type?.name === 'StaticResource') {
@@ -55,7 +55,9 @@ export class MetadataExpander {
5555
};
5656
}
5757

58-
private expandMetadataChildren(name: string, type: MetadataType, metadata: object): Record<string, Buffer> {
58+
private expandMetadataChildren(name: string, type: MetadataType, content: Buffer): Record<string, Buffer> {
59+
const attributeNode = '$';
60+
const metadata = XML.parse(content, { attributeNode });
5961
const expandedMetadata: Record<string, Buffer> = {};
6062

6163
if (!type.children) {
@@ -71,7 +73,21 @@ export class MetadataExpander {
7173
throw new Error(`Missing unique identifier for child type ${childType.name} in metadata ${type.name}`);
7274
}
7375
const childFileName = path.posix.join(name, childType.directoryName, `${childName}.${childType.suffix}-meta.xml`);
74-
expandedMetadata[childFileName] = Buffer.from(XML.stringify({ [childType.name]: childItem}, 4));
76+
const childXml = XML.stringify(
77+
{
78+
[childType.name]: {
79+
...childItem,
80+
[attributeNode]: metadata[attributeNode] ?? {
81+
xmlns: MetadataRegistry.xmlNamespace
82+
}
83+
}
84+
},
85+
{
86+
indent: 4,
87+
attributePrefix: attributeNode
88+
}
89+
);
90+
expandedMetadata[childFileName] = Buffer.from(childXml);
7591
}
7692

7793
if (childContent) {

packages/salesforce/src/metadataRegistry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ class MetadataInfoStore {
109109
* the build date determines the API version of the metadata registry.
110110
*/
111111
export namespace MetadataRegistry {
112+
/**
113+
* XML namespace used for Salesforce metadata XML files
114+
*/
115+
export const xmlNamespace = 'http://soap.sforce.com/2006/04/metadata';
112116

113117
/**
114118
* Singleton instance of the metadata registry store

0 commit comments

Comments
 (0)