diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index d01842cff..18e8d6058 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -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 { @@ -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 }; /** @@ -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) @@ -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 */ diff --git a/src/client/metadataApiRetrieve.ts b/src/client/metadataApiRetrieve.ts index fce3e93e0..58e2af6a2 100644 --- a/src/client/metadataApiRetrieve.ts +++ b/src/client/metadataApiRetrieve.ts @@ -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'); @@ -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 diff --git a/src/client/utils.ts b/src/client/utils.ts index c09d9c25e..2c4403578 100644 --- a/src/client/utils.ts +++ b/src/client/utils.ts @@ -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); +}; diff --git a/test/client/metadataApiRetrieve.test.ts b/test/client/metadataApiRetrieve.test.ts index d2f7fb631..93d4180c4 100644 --- a/test/client/metadataApiRetrieve.test.ts +++ b/test/client/metadataApiRetrieve.test.ts @@ -176,7 +176,7 @@ describe('MetadataApiRetrieve', () => { const toRetrieve = new ComponentSet([COMPONENT]); const options = { toRetrieve, - rootTypesWithDependencies: [ 'Bot' ], + rootTypesWithDependencies: ['Bot'], merge: true, successes: toRetrieve, }; @@ -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/ + 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'); + }); }); }); diff --git a/test/client/utils.test.ts b/test/client/utils.test.ts new file mode 100644 index 000000000..a04603957 --- /dev/null +++ b/test/client/utils.test.ts @@ -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; + }); + }); +});