Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 10 additions & 18 deletions src/client/deployMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { basename, dirname, extname, join, posix, sep } from 'node:path';
import { SfError } from '@salesforce/core/sfError';
import { ensureArray } from '@salesforce/kit';
import { SourceComponentWithContent, SourceComponent } from '../resolve/sourceComponent';
import { SourceComponent } from '../resolve/sourceComponent';
import { ComponentLike } from '../resolve';
import { registry } from '../registry/registry';
import {
Expand All @@ -30,7 +30,7 @@ import {
MetadataApiDeployStatus,
} from './types';
import { parseDeployDiagnostic } from './diagnosticUtil';
import { isWebAppBundle } from './utils';
import { isWebAppBundle, computeWebAppPathName } from './utils';

type DeployMessageWithComponentType = DeployMessage & { componentType: string };
/**
Expand Down Expand Up @@ -100,12 +100,14 @@ export const createResponses =
state,
filePath: component.content,
},
...component.walkContent().map((filePath) => ({
fullName: getWebAppBundleContentFullName(component)(filePath),
type: 'DigitalExperience',
state,
filePath,
})),
...component.walkContent().map(
(filePath): FileResponseSuccess => ({
fullName: computeWebAppPathName(filePath),
type: 'DigitalExperience',
state,
filePath,
})
),
]
: [
...(shouldWalkContent(component)
Expand All @@ -124,16 +126,6 @@ export const createResponses =
})) satisfies FileResponseSuccess[];
});

const getWebAppBundleContentFullName =
(component: SourceComponentWithContent) =>
(filePath: string): string => {
// Normalize paths to ensure relative() works correctly on Windows
const normalizedContent = component.content.split(sep).join(posix.sep);
const normalizedFilePath = filePath.split(sep).join(posix.sep);
const relPath = posix.relative(normalizedContent, normalizedFilePath);
return posix.join(component.fullName, relPath);
};

/**
* Groups messages from the deploy result by component fullName and type
*/
Expand Down
20 changes: 14 additions & 6 deletions src/client/metadataApiRetrieve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
import { extract } from './retrieveExtract';
import { getPackageOptions } from './retrieveExtract';
import { MetadataApiRetrieveOptions } from './types';
import { isWebAppBundle } from './utils';
import { isWebAppBundle, computeWebAppPathName } from './utils';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
Expand Down Expand Up @@ -112,11 +112,19 @@ export class RetrieveResult implements MetadataTransferResult {
// Special handling for web_app bundles - they need to walk content and report individual files
if (isWebAppBundle(retrievedComponent)) {
// Add the bundle directory itself
this.fileResponses.push(
...[retrievedComponent.content, ...retrievedComponent.walkContent()].map(
(filePath) => ({ ...baseResponse, filePath } satisfies FileResponseSuccess)
)
);
this.fileResponses.push({
...baseResponse,
filePath: retrievedComponent.content,
} satisfies FileResponseSuccess);
// Add individual files with path-based fullNames
for (const filePath of retrievedComponent.walkContent()) {
this.fileResponses.push({
fullName: computeWebAppPathName(filePath),
type: 'DigitalExperience',
state: this.localComponents.has(retrievedComponent) ? ComponentStatus.Changed : ComponentStatus.Created,
filePath,
} satisfies FileResponseSuccess);
}
} else if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) {
this.fileResponses.push(
...retrievedComponent
Expand Down
18 changes: 18 additions & 0 deletions src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { posix, sep } from 'node:path';
import { SourceComponent, SourceComponentWithContent } from '../resolve/sourceComponent';

export const isWebAppBundle = (component: SourceComponent): component is SourceComponentWithContent =>
component.type.name === 'DigitalExperienceBundle' &&
component.fullName.startsWith('web_app/') &&
typeof component.content === 'string';

/**
* Computes the path-based fullName for a file within a web_app bundle.
* Example: /path/to/digitalExperiences/web_app/ReactDemo/src/App.jsx -> web_app/ReactDemo/src/App.jsx
*/
export const computeWebAppPathName = (filePath: string): string => {
const pathParts = filePath.split(sep);
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');

if (digitalExperiencesIndex === -1) {
return filePath;
}

// Return path from baseType onwards (web_app/bundleName/file)
// Always use forward slashes for metadata names
return pathParts.slice(digitalExperiencesIndex + 1).join(posix.sep);
};
49 changes: 48 additions & 1 deletion test/client/metadataApiRetrieve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe('MetadataApiRetrieve', () => {
const toRetrieve = new ComponentSet([COMPONENT]);
const options = {
toRetrieve,
rootTypesWithDependencies: [ 'Bot' ],
rootTypesWithDependencies: ['Bot'],
merge: true,
successes: toRetrieve,
};
Expand Down Expand Up @@ -755,5 +755,52 @@ hY2thZ2VkL3BhY2thZ2UueG1sUEsFBgAAAAADAAMA7QAAAJoCAAAAAA==`;

expect(responses).to.deep.equal(expected);
});

it('should return FileResponses for web_app DigitalExperienceBundle with path-based fullNames', () => {
const bundlePath = join('path', 'to', 'digitalExperiences', 'web_app', 'ReactDemo');
const props = {
name: 'web_app/ReactDemo',
type: registry.types.digitalexperiencebundle,
content: bundlePath,
};
const component = SourceComponent.createVirtualComponent(props, [
{
dirPath: bundlePath,
children: ['webapp.json', 'index.html', 'app.js'],
},
]);
const retrievedSet = new ComponentSet([component]);
const localSet = new ComponentSet([component]);
const apiStatus = {};
const result = new RetrieveResult(apiStatus as MetadataApiRetrieveStatus, retrievedSet, localSet);

const responses = result.getFileResponses();

// Should have 1 bundle response + 3 file responses
expect(responses).to.have.lengthOf(4);

// First response should be the bundle
expect(responses[0]).to.deep.include({
fullName: 'web_app/ReactDemo',
type: 'DigitalExperienceBundle',
state: ComponentStatus.Changed,
filePath: bundlePath,
});

// Remaining responses should be DigitalExperience child files with path-based fullNames
const childResponses = responses.slice(1);
childResponses.forEach((response) => {
expect(response.type).to.equal('DigitalExperience');
// fullName should be path-based: web_app/ReactDemo/<filename>
expect(response.fullName).to.match(/^web_app\/ReactDemo\//);
expect(response.state).to.equal(ComponentStatus.Changed);
});

// Verify specific fullNames are path-based
const fullNames = childResponses.map((r) => r.fullName);
expect(fullNames).to.include('web_app/ReactDemo/webapp.json');
expect(fullNames).to.include('web_app/ReactDemo/index.html');
expect(fullNames).to.include('web_app/ReactDemo/app.js');
});
});
});
133 changes: 133 additions & 0 deletions test/client/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright 2025, 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 } from 'node:path';
import { expect } from 'chai';
import { computeWebAppPathName, isWebAppBundle } from '../../src/client/utils';
import { SourceComponent, registry } from '../../src';

describe('client/utils', () => {
describe('computeWebAppPathName', () => {
it('should return path-based name for web_app bundle file', () => {
const filePath = join(
'force-app',
'main',
'default',
'digitalExperiences',
'web_app',
'ReactDemo',
'src',
'App.jsx'
);
const result = computeWebAppPathName(filePath);
expect(result).to.equal('web_app/ReactDemo/src/App.jsx');
});

it('should handle nested directories', () => {
const filePath = join(
'force-app',
'main',
'default',
'digitalExperiences',
'web_app',
'MyApp',
'src',
'components',
'Button.jsx'
);
const result = computeWebAppPathName(filePath);
expect(result).to.equal('web_app/MyApp/src/components/Button.jsx');
});

it('should handle webapp.json file', () => {
const filePath = join(
'force-app',
'main',
'default',
'digitalExperiences',
'web_app',
'ReactDemo',
'webapp.json'
);
const result = computeWebAppPathName(filePath);
expect(result).to.equal('web_app/ReactDemo/webapp.json');
});

it('should handle public directory files', () => {
const filePath = join(
'force-app',
'main',
'default',
'digitalExperiences',
'web_app',
'ReactDemo',
'public',
'index.html'
);
const result = computeWebAppPathName(filePath);
expect(result).to.equal('web_app/ReactDemo/public/index.html');
});

it('should return original path if digitalExperiences not found', () => {
const filePath = join('some', 'other', 'path', 'file.js');
const result = computeWebAppPathName(filePath);
expect(result).to.equal(filePath);
});

it('should always use forward slashes in output', () => {
const filePath = join('project', 'digitalExperiences', 'web_app', 'Demo', 'src', 'index.js');
const result = computeWebAppPathName(filePath);
expect(result).to.not.include('\\');
expect(result).to.equal('web_app/Demo/src/index.js');
});
});

describe('isWebAppBundle', () => {
it('should return true for web_app DigitalExperienceBundle with content', () => {
const component = new SourceComponent({
name: 'web_app/ReactDemo',
type: registry.types.digitalexperiencebundle,
content: '/path/to/digitalExperiences/web_app/ReactDemo',
});
expect(isWebAppBundle(component)).to.be.true;
});

it('should return false for non-web_app DigitalExperienceBundle', () => {
const component = new SourceComponent({
name: 'site/MySite',
type: registry.types.digitalexperiencebundle,
content: '/path/to/digitalExperiences/site/MySite',
});
expect(isWebAppBundle(component)).to.be.false;
});

it('should return false for DigitalExperienceBundle without content', () => {
const component = new SourceComponent({
name: 'web_app/ReactDemo',
type: registry.types.digitalexperiencebundle,
});
expect(isWebAppBundle(component)).to.be.false;
});

it('should return false for non-DigitalExperienceBundle type', () => {
const component = new SourceComponent({
name: 'MyClass',
type: registry.types.apexclass,
content: '/path/to/classes/MyClass.cls',
});
expect(isWebAppBundle(component)).to.be.false;
});
});
});