diff --git a/src/client/deployMessages.ts b/src/client/deployMessages.ts index d7675b35f..28a554f23 100644 --- a/src/client/deployMessages.ts +++ b/src/client/deployMessages.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { basename, dirname, extname, join, posix, sep } from 'node:path/posix'; +import { basename, dirname, extname, join, posix, sep } from 'node:path'; import { SfError } from '@salesforce/core/sfError'; import { ensureArray } from '@salesforce/kit'; import { ComponentLike, SourceComponent } from '../resolve'; @@ -87,6 +87,34 @@ export const createResponses = (component: SourceComponent, responseMessages: De if (state === ComponentStatus.Failed) { return [{ ...base, state, ...parseDeployDiagnostic(component, message) } satisfies FileResponseFailure]; } else { + const isWebAppBundle = + component.type.name === 'DigitalExperienceBundle' && + component.fullName.startsWith('web_app/') && + component.content; + + if (isWebAppBundle) { + const walkedPaths = component.walkContent(); + const bundleResponse: FileResponseSuccess = { + fullName: component.fullName, + type: component.type.name, + state, + filePath: component.content!, + }; + const fileResponses: FileResponseSuccess[] = walkedPaths.map((filePath) => { + // 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 { + fullName: posix.join(component.fullName, relPath), + type: 'DigitalExperience', + state, + filePath, + }; + }); + return [bundleResponse, ...fileResponses]; + } + return [ ...(shouldWalkContent(component) ? component.walkContent().map((filePath): FileResponseSuccess => ({ ...base, state, filePath })) diff --git a/src/client/metadataApiRetrieve.ts b/src/client/metadataApiRetrieve.ts index 554395edc..51a2e6d28 100644 --- a/src/client/metadataApiRetrieve.ts +++ b/src/client/metadataApiRetrieve.ts @@ -101,14 +101,25 @@ export class RetrieveResult implements MetadataTransferResult { // construct successes for (const retrievedComponent of this.components.getSourceComponents()) { - const { fullName, type, xml } = retrievedComponent; + const { fullName, type, xml, content } = retrievedComponent; const baseResponse = { fullName, type: type.name, state: this.localComponents.has(retrievedComponent) ? ComponentStatus.Changed : ComponentStatus.Created, } as const; - if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) { + // Special handling for web_app bundles - they need to walk content and report individual files + const isWebAppBundle = type.name === 'DigitalExperienceBundle' && fullName.startsWith('web_app/') && content; + + if (isWebAppBundle) { + const walkedPaths = retrievedComponent.walkContent(); + // Add the bundle directory itself + this.fileResponses.push({ ...baseResponse, filePath: content } satisfies FileResponseSuccess); + // Add each file with its specific path + for (const filePath of walkedPaths) { + this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); + } + } else if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) { for (const filePath of retrievedComponent.walkContent()) { this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess); } diff --git a/src/convert/transformers/defaultMetadataTransformer.ts b/src/convert/transformers/defaultMetadataTransformer.ts index e8ea01dec..308a07d91 100644 --- a/src/convert/transformers/defaultMetadataTransformer.ts +++ b/src/convert/transformers/defaultMetadataTransformer.ts @@ -85,7 +85,9 @@ const getContentSourceDestination = ( if (mergeWith?.content) { if (component.content && component.tree.isDirectory(component.content)) { // DEs are always inside a dir. - if (component.type.strategies?.adapter === 'digitalExperience') { + // For web_app base type, use standard relative path logic (no ContentType folders) + const isWebApp = source.includes(`${sep}web_app${sep}`); + if (component.type.strategies?.adapter === 'digitalExperience' && !isWebApp) { const parts = source.split(sep); const file = parts.pop() ?? ''; const dir = join(mergeWith.content, parts.pop() ?? ''); diff --git a/src/resolve/adapters/digitalExperienceSourceAdapter.ts b/src/resolve/adapters/digitalExperienceSourceAdapter.ts index 1a0bf2f46..ebcc2afe7 100644 --- a/src/resolve/adapters/digitalExperienceSourceAdapter.ts +++ b/src/resolve/adapters/digitalExperienceSourceAdapter.ts @@ -25,6 +25,10 @@ import { BundleSourceAdapter } from './bundleSourceAdapter'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); + +// Constants for DigitalExperience base types +const WEB_APP_BASE_TYPE = 'web_app'; + /** * Source Adapter for DigitalExperience metadata types. This metadata type is a bundled type of the format * @@ -58,18 +62,57 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd * content/ * ├── bars/ * | ├── bars.digitalExperience-meta.xml + * web_app/ + * ├── zenith/ + * | ├── css/ + * | | ├── header/ + * | | | ├── header.css + * | | ├── home.css + * | ├── js/ + * | | ├── home.js + * | ├── html/ + * | | ├── home.html + * | ├── images/ + * | | ├── logos/ + * | | | ├── logo.png * ``` * * In the above structure the metadata xml file ending with "digitalExperience-meta.xml" belongs to DigitalExperienceBundle MD type. * The "_meta.json" files are child metadata files of DigitalExperienceBundle belonging to DigitalExperience MD type. The rest of the files in the * corresponding folder are the contents to the DigitalExperience metadata. So, incase of DigitalExperience the metadata file is a JSON file - * and not an XML file + * and not an XML file. + * + * For web_app base type, the bundle is identified by directory structure alone without metadata XML files. */ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter { + public getComponent(path: SourcePath, isResolvingSource = true): SourceComponent | undefined { + if (this.isBundleType() && isWebAppBaseType(path) && this.tree.isDirectory(path)) { + const pathParts = path.split(sep); + const bundleNameIndex = getDigitalExperiencesIndex(path) + 2; + if (bundleNameIndex === pathParts.length - 1) { + return this.populate(path, undefined); + } + } + return super.getComponent(path, isResolvingSource); + } + + protected parseAsRootMetadataXml(path: string): MetadataXml | undefined { + if (isWebAppBaseType(path)) { + return undefined; + } + if (!this.isBundleType() && !path.endsWith(this.type.metaFileSuffix ?? '_meta.json')) { + return undefined; + } + return super.parseAsRootMetadataXml(path); + } + protected getRootMetadataXmlPath(trigger: string): string { if (this.isBundleType()) { return this.getBundleMetadataXmlPath(trigger); } + if (isWebAppBaseType(trigger)) { + return ''; + } // metafile name = metaFileSuffix for DigitalExperience. if (!this.type.metaFileSuffix) { throw messages.createError('missingMetaFileSuffix', [this.type.name]); @@ -81,6 +124,10 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter { if (this.isBundleType()) { return path; } + if (isWebAppBaseType(path)) { + // For web_app, trim to the bundle directory: digitalExperiences/web_app/WebApp + return getWebAppBundleDir(path); + } const pathToContent = dirname(path); const parts = pathToContent.split(sep); /* Handle mobile or tablet variants.Eg- digitalExperiences/site/lwr11/sfdc_cms__view/home/mobile/mobile.json @@ -104,6 +151,9 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter { // for top level types we don't need to resolve parent return component; } + if (isWebAppBaseType(trigger)) { + return this.populateWebAppBundle(trigger, component); + } const source = super.populate(trigger, component); const parentType = this.registry.getParentType(this.type.id); // we expect source, parentType and content to be defined. @@ -144,9 +194,49 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter { } } + private populateWebAppBundle(trigger: string, component?: SourceComponent): SourceComponent { + if (component) { + return component; + } + + const pathParts = trigger.split(sep); + const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences'); + + // Extract bundle name: web_app/WebApp3 (always use posix separator for metadata names) + const baseType = pathParts[digitalExperiencesIndex + 1]; + const spaceApiName = pathParts[digitalExperiencesIndex + 2]; + const bundleName = [baseType, spaceApiName].join('/'); + + // Extract bundle directory: /path/to/digitalExperiences/web_app/WebApp3 + const bundleDir = getWebAppBundleDir(trigger); + + // Get the DigitalExperienceBundle type + const parentType = this.isBundleType() ? this.type : this.registry.getParentType(this.type.id); + if (!parentType) { + throw messages.createError('error_failed_convert', [bundleName]); + } + + return new SourceComponent( + { + name: bundleName, + type: parentType, + content: bundleDir, + }, + this.tree, + this.forceIgnore + ); + } + private getBundleName(contentPath: string): string { + if (isWebAppBaseType(contentPath)) { + const pathParts = contentPath.split(sep); + const digitalExperiencesIndex = getDigitalExperiencesIndex(contentPath); + const baseType = pathParts[digitalExperiencesIndex + 1]; + const spaceApiName = pathParts[digitalExperiencesIndex + 2]; + return [baseType, spaceApiName].join('/'); + } const bundlePath = this.getBundleMetadataXmlPath(contentPath); - return `${parentName(dirname(bundlePath))}/${parentName(bundlePath)}`; + return [parentName(dirname(bundlePath)), parentName(bundlePath)].join('/'); } private getBundleMetadataXmlPath(path: string): string { @@ -154,6 +244,9 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter { // if this is the bundle type and it ends with -meta.xml, then this is the bundle metadata xml path return path; } + if (isWebAppBaseType(path)) { + return ''; + } const pathParts = path.split(sep); const typeFolderIndex = pathParts.lastIndexOf(this.type.directoryName); // 3 because we want 'digitalExperiences' directory, 'baseType' directory and 'bundleName' directory @@ -177,3 +270,38 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter { const calculateNameFromPath = (contentPath: string): string => `${parentName(contentPath)}/${baseName(contentPath)}`; const digitalExperienceStructure = join('BaseType', 'SpaceApiName', 'ContentType', 'ContentApiName'); const contentParts = digitalExperienceStructure.split(sep); + +/** + * Checks if the given path belongs to the web_app base type. + * web_app base type has a simpler structure without ContentType folders. + * Structure: digitalExperiences/web_app/spaceApiName/...files... + */ +export const isWebAppBaseType = (path: string): boolean => { + const pathParts = path.split(sep); + const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences'); + return pathParts[digitalExperiencesIndex + 1] === WEB_APP_BASE_TYPE; +}; + +/** + * Gets the digitalExperiences index from a path. + * Returns -1 if not found. + */ +const getDigitalExperiencesIndex = (path: string): number => { + const pathParts = path.split(sep); + return pathParts.indexOf('digitalExperiences'); +}; + +/** + * Gets the web_app bundle directory path. + * For a path like: /path/to/digitalExperiences/web_app/WebApp/src/App.js + * Returns: /path/to/digitalExperiences/web_app/WebApp + */ +const getWebAppBundleDir = (path: string): string => { + const pathParts = path.split(sep); + const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences'); + if (digitalExperiencesIndex > -1 && pathParts.length > digitalExperiencesIndex + 3) { + // Return up to digitalExperiences/web_app/spaceApiName + return pathParts.slice(0, digitalExperiencesIndex + 3).join(sep); + } + return path; +}; diff --git a/src/resolve/metadataResolver.ts b/src/resolve/metadataResolver.ts index 4349801d4..e3a0c1c69 100644 --- a/src/resolve/metadataResolver.ts +++ b/src/resolve/metadataResolver.ts @@ -27,6 +27,7 @@ import { SourceAdapterFactory } from './adapters/sourceAdapterFactory'; import { ForceIgnore } from './forceIgnore'; import { SourceComponent } from './sourceComponent'; import { NodeFSTreeContainer, TreeContainer } from './treeContainers'; +import { isWebAppBaseType } from './adapters/digitalExperienceSourceAdapter'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -224,6 +225,15 @@ const resolveDirectoryAsComponent = (registry: RegistryAccess) => (tree: TreeContainer) => (dirPath: string): boolean => { + // For web_app bundles, only the bundle directory itself should be resolved as a component + // (e.g., digitalExperiences/web_app/WebApp), not subdirectories like src/, public/, etc. + if (isWebAppBaseType(dirPath)) { + const pathParts = dirPath.split(sep); + const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences'); + // The bundle directory is exactly 3 levels deep: digitalExperiences/web_app/bundleName + return digitalExperiencesIndex !== -1 && pathParts.length === digitalExperiencesIndex + 3; + } + const type = resolveType(registry)(tree)(dirPath); if (type) { const { directoryName, inFolder } = type; @@ -335,6 +345,10 @@ const resolveType = (registry: RegistryAccess) => (tree: TreeContainer) => (fsPath: string): MetadataType | undefined => { + if (isWebAppBaseType(fsPath)) { + return registry.getTypeByName('DigitalExperienceBundle'); + } + // attempt 1 - check if the file is part of a component that requires a strict type folder let resolvedType = resolveTypeFromStrictFolder(registry)(fsPath); diff --git a/test/client/metadataApiDeploy.test.ts b/test/client/metadataApiDeploy.test.ts index ace098fd6..38075544f 100644 --- a/test/client/metadataApiDeploy.test.ts +++ b/test/client/metadataApiDeploy.test.ts @@ -1218,6 +1218,57 @@ describe('MetadataApiDeploy', () => { expect(responses).to.deep.equal(expected); }); + it('should return FileResponses for web_app DigitalExperienceBundle with child files', () => { + const bundlePath = join('path', 'to', 'digitalExperiences', 'web_app', 'zenith'); + const props = { + name: 'web_app/zenith', + type: registry.types.digitalexperiencebundle, + content: bundlePath, + }; + const component = SourceComponent.createVirtualComponent(props, [ + { + dirPath: bundlePath, + children: ['index.html', 'app.js', 'style.css'], + }, + ]); + const deployedSet = new ComponentSet([component]); + const { fullName, type } = component; + const apiStatus: Partial = { + details: { + componentSuccesses: { + changed: 'false', + created: 'true', + deleted: 'false', + success: 'true', + fullName, + componentType: type.name, + } as DeployMessage, + }, + }; + const result = new DeployResult(apiStatus as MetadataApiDeployStatus, deployedSet); + + 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/zenith', + type: 'DigitalExperienceBundle', + state: ComponentStatus.Created, + filePath: bundlePath, + }); + + // Remaining responses should be DigitalExperience child files + const childResponses = responses.slice(1); + childResponses.forEach((response) => { + expect(response.type).to.equal('DigitalExperience'); + expect(response.fullName).to.match(/^web_app\/zenith\//); + expect(response.state).to.equal(ComponentStatus.Created); + }); + }); + it('should cache fileResponses', () => { const component = COMPONENT; const deployedSet = new ComponentSet([component]); diff --git a/test/resolve/adapters/digitalExperienceSourceAdapter.test.ts b/test/resolve/adapters/digitalExperienceSourceAdapter.test.ts index b0ad7c38d..b143174e7 100644 --- a/test/resolve/adapters/digitalExperienceSourceAdapter.test.ts +++ b/test/resolve/adapters/digitalExperienceSourceAdapter.test.ts @@ -192,4 +192,26 @@ describe('DigitalExperienceSourceAdapter', () => { }); }); }); + + describe('DigitalExperienceSourceAdapter for web_app base type', () => { + const WEBAPP_BUNDLE_PATH = join(BASE_PATH, 'web_app', 'zenith'); + const WEBAPP_CSS_FILE = join(WEBAPP_BUNDLE_PATH, 'css', 'home.css'); + + const webappTree = VirtualTreeContainer.fromFilePaths([WEBAPP_CSS_FILE]); + + const webappBundleAdapter = new DigitalExperienceSourceAdapter( + registry.types.digitalexperiencebundle, + registryAccess, + forceIgnore, + webappTree + ); + + it('should return a SourceComponent for web_app bundle directory (no meta.xml required)', () => { + const component = webappBundleAdapter.getComponent(WEBAPP_BUNDLE_PATH); + expect(component).to.not.be.undefined; + expect(component?.type.name).to.equal('DigitalExperienceBundle'); + expect(component?.fullName).to.equal('web_app/zenith'); + expect(component?.content).to.equal(WEBAPP_BUNDLE_PATH); + }); + }); }); diff --git a/test/resolve/metadataResolver.test.ts b/test/resolve/metadataResolver.test.ts index a04bf978e..d63b1874f 100644 --- a/test/resolve/metadataResolver.test.ts +++ b/test/resolve/metadataResolver.test.ts @@ -266,6 +266,22 @@ describe('MetadataResolver', () => { expect(mdResolver.getComponentsFromPath(path)).to.deep.equal([expectedComponent]); }); + it('Should determine type for web_app DigitalExperienceBundle (no meta.xml required)', () => { + const bundlePath = join('unpackaged', 'digitalExperiences', 'web_app', 'zenith'); + const filePath = join(bundlePath, 'index.html'); + const treeContainer = VirtualTreeContainer.fromFilePaths([filePath]); + const mdResolver = new MetadataResolver(undefined, treeContainer); + const expectedComponent = new SourceComponent( + { + name: 'web_app/zenith', + type: registry.types.digitalexperiencebundle, + content: bundlePath, + }, + treeContainer + ); + expect(mdResolver.getComponentsFromPath(bundlePath)).to.deep.equal([expectedComponent]); + }); + it('Should determine type for path of mixed content type', () => { const path = mixedContentDirectory.MIXED_CONTENT_DIRECTORY_SOURCE_PATHS[1]; const access = testUtil.createMetadataResolver([ diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/package.json b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/package.json new file mode 100644 index 000000000..9f2ecda99 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/package.json @@ -0,0 +1,28 @@ +{ + "name": "webapp2", + "version": "0.1.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/public/index.html b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/public/index.html new file mode 100644 index 000000000..231dd3c87 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/public/index.html @@ -0,0 +1,14 @@ + + + + + + + + WebApp2 + + + +
+ + diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/App.css b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/App.css new file mode 100644 index 000000000..a3a8a2d39 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/App.css @@ -0,0 +1,35 @@ +.App { + text-align: center; +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.card { + padding: 2rem; + background-color: #1a1d23; + border-radius: 8px; + margin-top: 2rem; + max-width: 600px; +} + +.card h2 { + margin-top: 0; + color: #61dafb; +} + +code { + background-color: #1a1d23; + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-family: 'Courier New', monospace; + color: #61dafb; +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/App.js b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/App.js new file mode 100644 index 000000000..f39510ee9 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/App.js @@ -0,0 +1,21 @@ +import React from 'react'; +import './App.css'; + +function App() { + return ( +
+
+

Welcome to WebApp2

+

This is a dummy React application.

+
+

Getting Started

+

+ Edit src/App.js and save to reload. +

+
+
+
+ ); +} + +export default App; diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/index.css b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/index.css new file mode 100644 index 000000000..7323ae85c --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', + 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/index.js b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/index.js new file mode 100644 index 000000000..2cb1087e7 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/digitalExperiences/web_app/WebApp/src/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/package.xml b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/package.xml new file mode 100644 index 000000000..cd80b82e2 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-md-files.expected/package.xml @@ -0,0 +1,8 @@ + + + + web_app/WebApp + DigitalExperienceBundle + + 64.0 + diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/package.json b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/package.json new file mode 100644 index 000000000..9f2ecda99 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/package.json @@ -0,0 +1,28 @@ +{ + "name": "webapp2", + "version": "0.1.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/public/index.html b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/public/index.html new file mode 100644 index 000000000..231dd3c87 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/public/index.html @@ -0,0 +1,14 @@ + + + + + + + + WebApp2 + + + +
+ + diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/App.css b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/App.css new file mode 100644 index 000000000..a3a8a2d39 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/App.css @@ -0,0 +1,35 @@ +.App { + text-align: center; +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.card { + padding: 2rem; + background-color: #1a1d23; + border-radius: 8px; + margin-top: 2rem; + max-width: 600px; +} + +.card h2 { + margin-top: 0; + color: #61dafb; +} + +code { + background-color: #1a1d23; + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-family: 'Courier New', monospace; + color: #61dafb; +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/App.js b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/App.js new file mode 100644 index 000000000..f39510ee9 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/App.js @@ -0,0 +1,21 @@ +import React from 'react'; +import './App.css'; + +function App() { + return ( +
+
+

Welcome to WebApp2

+

This is a dummy React application.

+
+

Getting Started

+

+ Edit src/App.js and save to reload. +

+
+
+
+ ); +} + +export default App; diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/index.css b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/index.css new file mode 100644 index 000000000..7323ae85c --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', + 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/index.js b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/index.js new file mode 100644 index 000000000..2cb1087e7 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/__snapshots__/verify-source-files.expected/force-app/main/default/digitalExperiences/web_app/WebApp/src/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/package.json b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/package.json new file mode 100644 index 000000000..9f2ecda99 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/package.json @@ -0,0 +1,28 @@ +{ + "name": "webapp2", + "version": "0.1.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/public/index.html b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/public/index.html new file mode 100644 index 000000000..231dd3c87 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/public/index.html @@ -0,0 +1,14 @@ + + + + + + + + WebApp2 + + + +
+ + diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/App.css b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/App.css new file mode 100644 index 000000000..a3a8a2d39 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/App.css @@ -0,0 +1,35 @@ +.App { + text-align: center; +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.card { + padding: 2rem; + background-color: #1a1d23; + border-radius: 8px; + margin-top: 2rem; + max-width: 600px; +} + +.card h2 { + margin-top: 0; + color: #61dafb; +} + +code { + background-color: #1a1d23; + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-family: 'Courier New', monospace; + color: #61dafb; +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/App.js b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/App.js new file mode 100644 index 000000000..f39510ee9 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/App.js @@ -0,0 +1,21 @@ +import React from 'react'; +import './App.css'; + +function App() { + return ( +
+
+

Welcome to WebApp2

+

This is a dummy React application.

+
+

Getting Started

+

+ Edit src/App.js and save to reload. +

+
+
+
+ ); +} + +export default App; diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/index.css b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/index.css new file mode 100644 index 000000000..7323ae85c --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/index.css @@ -0,0 +1,11 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', + 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/index.js b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/index.js new file mode 100644 index 000000000..2cb1087e7 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/digitalExperiences/web_app/WebApp/src/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/package.xml b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/package.xml new file mode 100644 index 000000000..6e326fcd8 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/originalMdapi/package.xml @@ -0,0 +1,9 @@ + + + + web_app/WebApp + DigitalExperienceBundle + + 64.0 + + diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/sfdx-project.json b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/sfdx-project.json new file mode 100644 index 000000000..04448b855 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/sfdx-project.json @@ -0,0 +1,12 @@ +{ + "packageDirectories": [ + { + "path": "force-app", + "default": true + } + ], + "name": "digitalExperienceBundleWithWebappsProject", + "namespace": "", + "sfdcLoginUrl": "https://login.salesforce.com", + "sourceApiVersion": "64.0" +} diff --git a/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/snapshots.test.ts b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/snapshots.test.ts new file mode 100644 index 000000000..981e77ee9 --- /dev/null +++ b/test/snapshot/sampleProjects/digitalExperienceBundleWithWebapps/snapshots.test.ts @@ -0,0 +1,124 @@ +/* + * 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 * as fs from 'node:fs'; +import * as path from 'node:path'; +import { expect } from 'chai'; +import { + FORCE_APP, + MDAPI_OUT, + dirsAreIdentical, + fileSnap, + mdapiToSource, + sourceToMdapi, +} from '../../helper/conversions'; +import { ComponentSetBuilder, MetadataConverter, RegistryAccess } from '../../../../src'; + +// we don't want failing tests outputting over each other +/* eslint-disable no-await-in-loop */ + +describe('digitalExperienceBundleWithWebapps', () => { + const testDir = path.join('test', 'snapshot', 'sampleProjects', 'digitalExperienceBundleWithWebapps'); + let sourceFiles: string[]; + let mdFiles: string[]; + + before(async () => { + await fs.promises.mkdir(path.join(testDir, FORCE_APP), { recursive: true }); + sourceFiles = await mdapiToSource(testDir); + mdFiles = await sourceToMdapi(testDir); + }); + it('verify source files', async () => { + for (const file of sourceFiles) { + await fileSnap(file, testDir); + } + await dirsAreIdentical( + path.join(testDir, FORCE_APP), + path.join(testDir, '__snapshots__', 'verify-source-files.expected', FORCE_APP) + ); + }); + + it('verifies source files after two conversions', async () => { + // reads all files in a directory recursively + function getAllFiles(dirPath: string, fileList: string[] = []): string[] { + const files = fs.readdirSync(dirPath); + + for (const file of files) { + const filePath = path.join(dirPath, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + getAllFiles(filePath, fileList); // Recursive call for subdirectories + } else { + fileList.push(filePath); + } + } + + return fileList; + } + + // should contain correct file path for webapps + const fileList = getAllFiles(path.join(testDir, FORCE_APP)); + + await dirsAreIdentical( + path.join(testDir, FORCE_APP), + path.join(testDir, '__snapshots__', 'verify-source-files.expected', FORCE_APP) + ); + + // build a new CS from the freshly-converted source-format metadata + const cs = await ComponentSetBuilder.build({ + sourcepath: [path.join(testDir, 'originalMdapi')], + projectDir: testDir, + }); + const registry = new RegistryAccess(undefined, testDir); + const converter = new MetadataConverter(registry); + + // converts metadata format DEB into source-format, with a mergeWith option, merging into force-app + await converter.convert( + cs, + 'source', // loads custom registry if there is one + { + type: 'merge', + mergeWith: ( + await ComponentSetBuilder.build({ + sourcepath: [path.join(testDir, 'force-app')], + projectDir: testDir, + }) + ).getSourceComponents(), + defaultDirectory: path.join(testDir, 'force-app'), + } + ); + + // verify the file list is consistent after two conversions + const fileList2 = getAllFiles(path.join(testDir, FORCE_APP)); + expect(fileList2).to.deep.equal(fileList); + + await dirsAreIdentical( + path.join(testDir, FORCE_APP), + path.join(testDir, '__snapshots__', 'verify-source-files.expected', FORCE_APP) + ); + }); + it('verify md files', async () => { + for (const file of mdFiles) { + await fileSnap(file, testDir); + } + }); + + after(async () => { + await Promise.all([ + fs.promises.rm(path.join(testDir, FORCE_APP), { recursive: true, force: true }), + fs.promises.rm(path.join(testDir, MDAPI_OUT), { recursive: true, force: true }), + ]); + }); +});