OmniStudio : Automating LWC generation  in METADATA deployment flow

OmniStudio : Automating LWC generation in METADATA deployment flow

Salesforce OmniStudio provides powerful functionalities and allows developers to streamline complex business processes with ease. When dealing with deployments, particularly with OmniStudio Metadata, there's an additional step required – manually activating the OmniScript components in the target environment. But, why not automate this process?

The Issue:

For those who have been using the Vlocity build tool (or VBT), you might be aware that it facilitates the deployment of Omni components from one Salesforce org to another, given VBT or IDX fetches the components. However, a challenge arises if OmniStudio Metadata is enabled. Post-deployment of the OmniStudio components as metadata, developers are required to manually deactivate and then reactivate the OmniScript in the target environment.

The sfdx-git-delta (SGD) Tool:

Enter sfdx-git-delta. This remarkable tool is a game-changer for those striving to optimize Salesforce deployments. SGD pinpoints the differences between two Git branches or commits, facilitating the deployment of only the altered metadata components. This tool is a boon for CI/CD processes, ensuring efficient and selective deployments, especially pivotal for sizable Salesforce projects.

The Solution:

Capitalizing on the capabilities of SGD, I've tailored a script that eradicates the need for manual interventions. Post the deployment (and leveraging SGD to ensure only the necessary components are deployed), this script smartly identifies the delta and activates all the pertinent OmniScripts and FlexCards. This script can be smoothly embedded into your CI/CD pipeline.

Here's how you can incorporate it:

Project Structure:

1. Harnessing sfdx-git-delta:

First and foremost, utilize SGD to detect the delta between your branches or commits. This step ensures only the changed OmniStudio components are earmarked for deployment.

2. Splitting the Deployment:

Due to known complications when deploying a mixture of OmniStudio and core components, it's prudent to split your deployments. Start with deploying OmniStudio components as a Salesforce metadata deployment.

3. Activation Script:

After the OmniStudio component deployment, execute the script to activate all the necessary components. This script has been designed to ensure the components are not only activated but also logs detailed progress, aiding in troubleshooting if necessary.

4. Deploy Salesforce Core Components:

Once the OmniStudio components are active and ready and the generation of LWC is a success, then you can proceed to deploy the core Salesforce components.

Integration in CI/CD:

To give you an idea of how to incorporate this into a CI/CD process, here's a simple structure of a YAML file that works if you are deploying OmniStudio and activating OmniStudio after deployment. Below can be changed and modified accordingly but this is just for example.

name: Activate OmniScript

on:
  pull_request:
    branches:
      - master

jobs:
  deployOmniComponents:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '16' # or another version if preferred
      - name: Install Salesforce CLI
        run: |
          npm install sfdx-cli -g

      - name: Install Salesforce SFDX Git Delta
        run: |
          echo y | sfdx plugins:install sfdx-git-delta
          sfdx sgd:source:delta --to "HEAD" --from "HEAD^" --output changed-sources/ --generate-delta
      - uses: sfdx-actions/setup-sfdx@v1
        with:
          sfdx-auth-url: ${{secrets.AUTH_SF_SECRET_SALESFORCEORG}}
      - name: Deploy Delta Components
        run: |
          sfdx force:source:deploy -p changed-sources

  activateOmniComponents:
    needs: deployOmniComponents
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '16' # or another version if preferred
      - name: Install Salesforce CLI
        run: |
          npm install sfdx-cli -g
      - name: Install Salesforce SFDX Git Delta
        run: |
          echo y | sfdx plugins:install sfdx-git-delta
          sfdx sgd:source:delta --to "HEAD" --from "HEAD^" --output changed-sources/ --generate-delta
      - uses: sfdx-actions/setup-sfdx@v1
        with:
          sfdx-auth-url: ${{secrets.AUTH_SF_SECRET_SALESFORCEORG}}
      - name: Install OmniAutomation
        run: |
          cd omnistudioAutomation
          npm install
          node activateScript.js changed-sources

ActivateScript.js code is as follows, (Code has been referenced from existing VBT opensource repository), In addition to that I have appended the logic to click "Deploy Standard Runtime Compatible LWC" so that it generates custom LWC for the component.

FlexCards generates LWC without clicking any button and we are already calling it from below as part of "FlexCardCompilePage".

NOTE: You can replace "packageNamespace" accordingly.

const puppeteer = require('puppeteer');
const { execSync } = require('child_process');
const fs = require('fs');
const xml2js = require('xml2js');

let pathToDeltaFolder = process.argv.slice(2);
const packageNamespace = 'omnistudio__';
const managedPackageRuntimeDisabled = process.env.managedPackageRuntimeEnabled !== undefined ? process.env.managedPackageRuntimeEnabled : true;

async function getComponentMapFromXML() {
    try {
        // Read the XML file content
        const xmlContent = fs.readFileSync(`../${pathToDeltaFolder}/package/package.xml`, 'utf8');

        const parser = new xml2js.Parser();

        // Parse XML to JSON
        const result = await parser.parseStringPromise(xmlContent);

        const typesArray = result.Package.types;

        let componentMap = {};

        typesArray.forEach(type => {
            if (type.name && type.name[0] && type.members) {
                componentMap[type.name[0]] = type.members;
            }
        });

        return componentMap;
    } catch (err) {
        console.log("Error parsing XML:", err);
        return {};
    }
}

async function compileOSAndFlexCards() {

    const componentMap = await getComponentMapFromXML();

    // Run the sfdx command
    const rawOutput = execSync(`sf org display --verbose --json`).toString('utf8');
    let rawOutputParsed = JSON.parse(rawOutput);
    let instanceUrl = rawOutputParsed.result.instanceUrl;
    let accessToken = rawOutputParsed.result.accessToken;

    let omniScriptsQueryCondition = "";
    let omniUiCardsQueryCondition = "";
    let omniScripts;
    let flexCards;

    // If delta has OmniScript reference then fetch the id from the name of the omniscript component
    if (componentMap.hasOwnProperty("OmniScript") && componentMap["OmniScript"].length > 0) {
        const omniScriptNames = componentMap["OmniScript"].map(name => `'${name}'`).join(',');
        omniScriptsQueryCondition = `AND UniqueName IN (${omniScriptNames})`;

        // Using sf query on OmniProcess for OmniScripts
        const queryOmniscript = `SELECT Id, UniqueName FROM OmniProcess WHERE IsActive = true AND IsIntegrationProcedure = false ${omniScriptsQueryCondition}`;
        const rawOutputFromOSQuery =  execSync(`sf data query --query "${queryOmniscript}" --json`)
        omniScripts = JSON.parse(rawOutputFromOSQuery).result;
    }

    // If delta has FlexCard reference then fetch the id from the name of the FlexCard component
    if (componentMap.hasOwnProperty("OmniUiCard") && componentMap["OmniUiCard"].length > 0) {
        const omniUiCardNames = componentMap["OmniUiCard"].map(name => `'${name}'`).join(',');
        omniUiCardsQueryCondition = `AND UniqueName IN (${omniUiCardNames})`;

        // Using sf query on OmniUiCard for FlexCards
        const queryFlexCard = `SELECT Id, UniqueName FROM OmniUiCard WHERE IsActive = true ${omniUiCardsQueryCondition}`;
        const rawOutputFromFlexCardQuery =  execSync(`sf data query --query "${queryFlexCard}" --json`)
        flexCards = JSON.parse(rawOutputFromFlexCardQuery).result;
    }


    //Initialize puppeteer options
    const puppeteerOptions = {
        headless: process.env.puppeteerHeadless !== undefined ? process.env.puppeteerHeadless : true,
        args: ['--no-sandbox', '--disable-setuid-sandbox']
    };

    let browser;
    try {
        let siteUrl = instanceUrl;
        let loginURl = `${siteUrl}/secur/frontdoor.jsp?sid=${accessToken}`;
        try {
            browser = await puppeteer.launch(puppeteerOptions);
        } catch (error) {
            console.log('Puppeteer initialization failed:', error);
            process.exit(1); // Exit with a non-zero exit code to signal failure
        }

        const page = await browser.newPage();
        const loginTimeout = 60000; // You may adjust this value as needed.

        await Promise.all([
            page.waitForNavigation({ timeout: loginTimeout, waitUntil: 'load' }),
            page.waitForNavigation({ timeout: loginTimeout, waitUntil: 'networkidle2' }),
            page.goto(loginURl, { timeout: loginTimeout })
        ]);

        const omniScriptIds = omniScripts !== undefined ? omniScripts.records.map(record => record.Id) : [];
        const flexCardsIds = flexCards !== undefined ? flexCards.records.map(record => record.Id) : [];

        //Loop in Omniscripts and active
        for(const eachOmniscriptId of omniScriptIds){
            let omniScriptDisignerpageLink = siteUrl + '/apex/' + packageNamespace + 'OmniLwcCompile?id=' + eachOmniscriptId + '&activate=true';
            await page.goto(omniScriptDisignerpageLink);
            await page.waitForTimeout(5000);

            let tries = 0;
            let errorMessage;
            let maxNumOfTries = 12; // Assuming a 5-second wait time, it gives a total of 1 minute. Adjust as needed.
            while (tries < maxNumOfTries) {
                try {
                    let message;
                    try {
                        message = await page.waitForSelector('#compiler-message');
                    } catch (messageTimeout) {
                        console.log('Loading Page taking too long. Retrying:', tries);
                    }

                    if (message) {
                        let currentStatus = await message.evaluate(node => node.innerText);
                        page.on('console', msg => {
                            for (let i = 0; i < msg.args().length; ++i)
                                console.log(`${i}: ${msg.args()[i]}`);
                        });
                        if (currentStatus === 'DONE') {
                            console.log('LWC Activated successfully');

                            // The below step is only required if Manged Package Runtime is disabled. Otherwise the above steps will generate a Custom LWC from OS
                            if(managedPackageRuntimeDisabled){
                                let omniscriptDesignerPageWrapper = siteUrl + '/lightning/cmp/' + packageNamespace + 'OmniDesignerAuraWrapper?c__recordId=' + eachOmniscriptId;
                                await page.goto(omniscriptDesignerPageWrapper);
                                await page.waitForSelector('section.tabContent.active.oneConsoleTab c-omni-designer-header lightning-button-menu button[part="button button-icon"]');
                                await page.waitForTimeout(2000); // This waits for 2 seconds. Adjust as needed.
                                await page.click('section.tabContent.active.oneConsoleTab c-omni-designer-header lightning-button-menu button[part="button button-icon"]');

                                let isMessageReceived = false;
                                // Expose a function to be called from the page context.
                                await page.exposeFunction('onMessageReceived', () => {
                                    isMessageReceived = true;
                                });

                                // Click on the "Deploy Standard Runtime Compatible LWC" option
                                await page.waitForSelector('section.tabContent.active.oneConsoleTab c-omni-designer-header a[role="menuitem"] > span.slds-truncate', { visible: true });
                                await page.waitForTimeout(2000); // This waits for 2 seconds. Adjust as needed.
                                let waitUntilMessageReceived = new Promise(async (resolve) => {
                                    await page.evaluate(() => {
                                        window.addEventListener('message', (event) => {
                                            if(event && event.data && event.data.key && event.data.status){
                                                if(event.data.status === 'DONE') {
                                                    window.onMessageReceived(); // Call the exposed function.
                                                }
                                                console.log('Status from omnilwccompile -> ' + event.data.status);
                                            }
                                        })

                                        const activeSection = document.querySelector('section.tabContent.active.oneConsoleTab');
                                        const menuItems = Array.from(activeSection.querySelectorAll('c-omni-designer-header a[role="menuitem"] > span.slds-truncate'));
                                        const targetItem = menuItems.find(item => item.textContent === "Deploy Standard Runtime Compatible LWC");
                                        targetItem && targetItem.click();
                                    });

                                    // Keep checking until the isMessageReceived flag becomes true.
                                    while (!isMessageReceived) {
                                        await page.waitForTimeout(1000); // Wait for 1 second before checking again.
                                    }
                                    resolve();
                                });
                                await waitUntilMessageReceived;
                            }
                            break;
                        } else if (currentStatus.startsWith('ERROR: No MODULE named markup')) {
                            errorMessage = 'Missing Custom LWC - ' + currentStatus;
                            break;
                        } else if (currentStatus.startsWith('ERROR')) {
                            errorMessage = 'Error Activating LWC - ' + currentStatus;
                            break;
                        }
                    }
                } catch (e) {
                    errorMessage = 'Error:' + e;
                }

                tries++;
                await page.waitForTimeout(5000); // This waits for 5 seconds. Adjust as needed.
            }

            if (tries === maxNumOfTries) {
                errorMessage = 'Activation took longer than expected - Aborting';
            }

            if (errorMessage) {
                console.log('LWC Activation Error:', errorMessage);
            }
        }


        //Loop in FlexCards and active
        if(flexCardsIds.length > 0) {
            let idsArrayString = flexCardsIds.join(',');
            let flexCardCompilePage = siteUrl + '/apex/' + packageNamespace + 'FlexCardCompilePage?id=' + idsArrayString;
            await page.goto(flexCardCompilePage, {timeout: loginTimeout});
            await page.waitForTimeout(5000);
            let tries = 0;
            const maxNumOfTries = 12; // Adjust based on your needs
            let errorMessage;

            while (tries < maxNumOfTries) {
                try {
                    const message = await page.waitForSelector('#compileMessage-0', { timeout: 5000 });

                    if (message) {
                        let currentStatus = await message.evaluate(node => node.innerText);
                        if (currentStatus === 'DONE SUCCESSFULLY') {
                            console.log('LWC Activated successfully');
                            break;
                        } else if (currentStatus === 'DONE WITH ERRORS') {
                            let jsonResulNode  = await page.waitForSelector('#resultJSON-0');
                            let jsonError = await jsonResulNode.evaluate(node => node.innerText);
                            console.log('LWC FlexCards Compilation Error Result:', jsonError);
                            break;
                        }
                    }
                } catch (e) {
                    console.log('Error Activating FlexCards:', e);
                }

                tries++;
                await page.waitForTimeout(5000);
            }

            if (tries === maxNumOfTries) {
                errorMessage = 'Activation took longer than expected - Aborted';
                process.exit(1); // Exit with a non-zero exit code to signal failure
            }
        }


    } catch (e) {
        try {
            await browser.close();
        } catch (e2) {
            process.exit(1); // Exit with a non-zero exit code to signal failure
        }
    }
}

// Call the function
try {
    compileOSAndFlexCards().then(r => {
        process.exit(0);
    }).catch(e => {
        process.exit(1); // Exit with a non-zero exit code to signal failure
    });
}  catch (error) {
    process.exit(1); // Exit with a non-zero exit code to signal failure
}

package.json for the above nodejs script.

{
  "name": "omnistudioautomation",
  "version": "1.0.0",
  "description": "",
  "main": "activateScript.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "puppeteer": "^21.1.0",
    "xml2js": "^0.6.2"
  }
}

Conclusion:

While deploying OmniStudio components as Salesforce metadata comes with its set of challenges, automation can streamline the process, saving both time and reducing potential human errors. As the Salesforce OmniStudio community grows and evolves, methods like these that integrate seamlessly with existing Salesforce metadata deployments are invaluable.

Incorporating such scripts into your CI/CD process can make deployments smoother, faster, and more efficient.

Join in on the fun of automation and let the handy machines take care of those repetitive tasks for you!

Did you find this article valuable?

Support Nagendra Singh by becoming a sponsor. Any amount is appreciated!