From 314e1448b5b88b74d6c3263824ccc7a088648c62 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Mon, 3 Aug 2026 14:07:46 +0200 Subject: [PATCH 1/4] remove redundant rum setup --- test/apps/sf-experience-app/README.md | 67 --------------------------- 1 file changed, 67 deletions(-) diff --git a/test/apps/sf-experience-app/README.md b/test/apps/sf-experience-app/README.md index 6f3fd0675b..1517e5dfcf 100644 --- a/test/apps/sf-experience-app/README.md +++ b/test/apps/sf-experience-app/README.md @@ -25,73 +25,6 @@ For the canonical RUM integration setup, see `[packages/browser-rum-slim/src/salesforce/README.md](../../../packages/browser-rum-slim/src/salesforce/README.md)` and `[test/apps/sf-lwc-app/README.md](../sf-lwc-app/README.md)`. -## RUM Setup - -### 1. Add the static resource - -Copy the Datadog RUM Salesforce bundle into the project as -`force-app/main/default/staticresources/datadog_rum_salesforce.js` and register it with -`datadog_rum_salesforce.resource-meta.xml`: - -```xml - - - Public - application/javascript - -``` - -### 2. Configure CSP for the Datadog intake endpoint - -Experience Cloud sites enforce CSP. In **Experience Builder → Settings → Security & Privacy**: - -- Change the security level from **Strict CSP** to **Relaxed CSP** -- Add the Datadog browser intake as a trusted site - -| Field | Value | -| ----- | -------------------------------------- | -| Name | `browser_intake_datadoghq_com` | -| URL | `https://browser-intake-datadoghq.com` | - -For non-US1 Datadog sites, use the intake endpoint for your site. - -(In this org the CSP trusted site is already deployed by `sf-lwc-app`; we do not need to add it from this app.) - -### 3. Create the Datadog init LWC - -This app uses `experienceDatadogInit` (`force-app/main/default/lwc/experienceDatadogInit/`). The -component: - -- Loads the `datadog_rum_salesforce` static resource via `lightning/platformResourceLoader` -- Initializes only when `?init=true` is present in the page URL -- Calls `DD_RUM.startView()` on each SPA navigation using `NavigationMixin` and `CurrentPageReference` -- Merges `window.RUM_CONFIGURATION` over default `xxx` credentials at init time - -See `experienceDatadogInit.js` for the implementation. A minimal HTML template is required: - -```html - -``` - -Component metadata exposes the bundle to Experience Builder: - -```xml - - lightningCommunity__Page - lightningCommunity__Default - -``` - -This test component does not take `applicationId` / `clientToken` as Experience Builder properties — credentials are injected at runtime via `window.RUM_CONFIGURATION`. - -### 4. Add the component to the Experience Builder theme - -Open the site in **Experience Builder** (Setup → Apps → App Manager → **Manage** → **Builder**) and -add **Experience Datadog Init** to a region that loads on every page (shared theme, header, footer, -or page template). - -Save and **publish** the site. - ## Authentication And URLs Deploy uses the same JWT-based flow as `sf-lwc-app`: From fe05e96d13c1d09053564af6d70f3ab4e983168d Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Tue, 4 Aug 2026 08:18:09 +0200 Subject: [PATCH 2/4] add headmarkup app --- .prettierignore | 1 + eslint.config.ts | 2 ++ scripts/build/build-test-apps.ts | 36 ++++++++++++++++++++++++++++++++ scripts/salesforce-apps.ts | 34 +++++++++++++++++++----------- test/apps/.gitignore | 1 + 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/.prettierignore b/.prettierignore index d9dd0d84ab..f4fc2059fc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,7 @@ yarn.lock # (e.g. "Event handler should be an expression"), which breaks deployment. /test/apps/sf-lwc-app/* /test/apps/sf-experience-app/* +/test/apps/sf-experience-headmarkup-app/* # Auto-generated by API Extractor — do not format /packages/js-core/api diff --git a/eslint.config.ts b/eslint.config.ts index ee6c756198..b1d8f25ac1 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -54,6 +54,7 @@ export default defineConfig( 'test/apps/nuxt-vue-router-v4-app', 'test/apps/sf-lwc-app/force-app/main/default/staticresources/*.js', 'test/apps/sf-experience-app/force-app/main/default/staticresources/*.js', + 'test/apps/sf-experience-headmarkup-app/force-app/main/default/staticresources/*.js', 'sandbox', 'coverage', '.yarn', @@ -464,6 +465,7 @@ export default defineConfig( files: [ 'test/apps/sf-lwc-app/force-app/main/default/lwc/**/*.js', 'test/apps/sf-experience-app/force-app/main/default/lwc/**/*.js', + 'test/apps/sf-experience-headmarkup-app/force-app/main/default/lwc/**/*.js', ], languageOptions: { globals: globals.browser, diff --git a/scripts/build/build-test-apps.ts b/scripts/build/build-test-apps.ts index 737df7ec59..e3e78351ff 100644 --- a/scripts/build/build-test-apps.ts +++ b/scripts/build/build-test-apps.ts @@ -63,6 +63,11 @@ const APPS: AppConfig[] = [ // Salesforce apps { name: 'sf-lwc-app', builderFn: buildSalesforceApp }, { name: 'sf-experience-app', builderFn: buildSalesforceApp }, + { + name: 'sf-experience-headmarkup-app', + builderFn: buildExperienceHeadMarkupApp, + deps: ['sf-experience-app'], + }, ] runMain(async () => { @@ -174,6 +179,37 @@ function buildSalesforceApp(appName: string) { fs.copyFileSync(sourceBundle, targetBundle) } +async function buildExperienceHeadMarkupApp() { + await buildGeneratedSalesforceApp('sf-experience-app', 'sf-experience-headmarkup-app', async (appPath) => { + await modifyPackageJson(appPath, (packageJson) => { + packageJson.name = 'sf-experience-headmarkup-app' + }) + + // This app exercises the Experience Cloud head markup init path, so it doesn't need the + // static-resource-loaded init LWC used by sf-experience-app. Best-effort removal: if it's + // not there, there's nothing to do. + fs.rmSync(path.join(appPath, 'force-app/main/default/lwc/experienceDatadogInit'), { + recursive: true, + force: true, + }) + }) +} + +async function buildGeneratedSalesforceApp( + baseAppName: string, + appName: string, + modifyApp: (appPath: string) => Promise +) { + const baseAppPath = `test/apps/${baseAppName}` + const appPath = `test/apps/${appName}` + + fs.rmSync(appPath, { recursive: true, force: true }) + fs.cpSync(baseAppPath, appPath, { recursive: true }) + + await modifyApp(appPath) + buildSalesforceApp(appName) +} + async function buildReactRouterV6App() { await buildGeneratedApp('react-router-app', 'react-router-v6-app', async (appPath) => { await modifyFile(path.join(appPath, 'package.json'), (content: string) => diff --git a/scripts/salesforce-apps.ts b/scripts/salesforce-apps.ts index 9cb6ab221e..48d5da6291 100644 --- a/scripts/salesforce-apps.ts +++ b/scripts/salesforce-apps.ts @@ -1,4 +1,4 @@ -import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import { tmpdir } from 'node:os' import { parseArgs } from 'node:util' @@ -9,12 +9,10 @@ import { command } from './lib/command.ts' const repositoryRoot = resolve(import.meta.dirname, '..') const TARGET_ORG = 'sf-lwc-ci' -const SALESFORCE_SOURCE_BUNDLE = resolve(repositoryRoot, 'packages/browser-rum-slim/bundle/datadog-rum-salesforce.js') -const SALESFORCE_STATIC_RESOURCE = 'force-app/main/default/staticresources/datadog_rum_salesforce.js' -type AppKey = 'lwc' | 'experience-cloud' +type AppKey = 'lwc' | 'experience-cloud' | 'experience-cloud-headmarkup' -const APP_KEYS: AppKey[] = ['lwc', 'experience-cloud'] +const APP_KEYS: AppKey[] = ['lwc', 'experience-cloud', 'experience-cloud-headmarkup'] const APPS: Record = { lwc: { @@ -26,6 +24,19 @@ const APPS: Record = { url: new URL('sfexperiencecloud/', getSalesforceSiteUrl()).href, siteName: 'SF Experience Cloud App', }, + 'experience-cloud-headmarkup': { + dir: resolve(repositoryRoot, 'test/apps/sf-experience-headmarkup-app'), + url: new URL('sfexperienceheadmarkup/', getSalesforceSiteUrl()).href, + siteName: 'SF Experience Cloud Head Markup App', + }, +} + +// Name of the corresponding app in scripts/build/build-test-apps.ts, used to (re)build the app +// (and refresh its RUM Salesforce bundle static resource) before deploying it. +const BUILD_APP_NAME: Record = { + lwc: 'sf-lwc-app', + 'experience-cloud': 'sf-experience-app', + 'experience-cloud-headmarkup': 'sf-experience-headmarkup-app', } const SUPPORTED_COMMANDS = ['deploy-apps', 'get-urls'] @@ -112,10 +123,15 @@ function deployApp(appKeys: AppKey[]) { printLog('Building RUM Salesforce bundle...') command`yarn workspace @datadog/browser-rum-slim build:bundle`.withLogs().run() + printLog('Building Salesforce apps...') + command`yarn build:apps ${appKeys.flatMap((appKey) => ['--app', BUILD_APP_NAME[appKey]])}` + .withCurrentWorkingDirectory(repositoryRoot) + .withLogs() + .run() + for (const appKey of appKeys) { const { dir, siteName } = APPS[appKey] - copySalesforceBundle(dir) authenticate(TARGET_ORG, dir) rmSync(resolve(dir, '.sf'), { recursive: true, force: true }) @@ -139,12 +155,6 @@ function deployApp(appKeys: AppKey[]) { } } -function copySalesforceBundle(appDirectory: string) { - const targetBundle = resolve(appDirectory, SALESFORCE_STATIC_RESOURCE) - printLog(`Refreshing static resource at ${targetBundle}...`) - copyFileSync(SALESFORCE_SOURCE_BUNDLE, targetBundle) -} - function printUrl(appKeys: AppKey[]): void { for (const appKey of appKeys) { const { url } = APPS[appKey] diff --git a/test/apps/.gitignore b/test/apps/.gitignore index aa74cb9755..e37d26ea2c 100644 --- a/test/apps/.gitignore +++ b/test/apps/.gitignore @@ -4,6 +4,7 @@ react-router-v6-app/ react-router-v7-app/ vue-router-v4-app/ nuxt-vue-router-v4-app/ +sf-experience-headmarkup-app/ cdn-extension/ appendChild-extension/ */dist/ From 8e5d38b8ccc2cf37a2bfd58d44d5e071f09c5c4e Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Wed, 12 Aug 2026 17:59:55 +0200 Subject: [PATCH 3/4] Fix name of Salesforce headmarkup app --- scripts/salesforce-apps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/salesforce-apps.ts b/scripts/salesforce-apps.ts index 48d5da6291..3fb532c8f7 100644 --- a/scripts/salesforce-apps.ts +++ b/scripts/salesforce-apps.ts @@ -27,7 +27,7 @@ const APPS: Record = { 'experience-cloud-headmarkup': { dir: resolve(repositoryRoot, 'test/apps/sf-experience-headmarkup-app'), url: new URL('sfexperienceheadmarkup/', getSalesforceSiteUrl()).href, - siteName: 'SF Experience Cloud Head Markup App', + siteName: 'SF Experience Cloud Head Markup', }, } From c2b1b4abc8a86bc56ed5f224995be98cf4f7ea31 Mon Sep 17 00:00:00 2001 From: "beltran.bulbarella" Date: Thu, 13 Aug 2026 10:09:37 +0200 Subject: [PATCH 4/4] Add headmarkup tests --- test/e2e/lib/framework/buildSalesforceUrl.ts | 13 ++++--- test/e2e/lib/framework/pageSetups.ts | 18 +++++++--- test/e2e/scenario/salesforce.scenario.ts | 38 +++++++++++++------- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/test/e2e/lib/framework/buildSalesforceUrl.ts b/test/e2e/lib/framework/buildSalesforceUrl.ts index 9956257220..9b2064384e 100644 --- a/test/e2e/lib/framework/buildSalesforceUrl.ts +++ b/test/e2e/lib/framework/buildSalesforceUrl.ts @@ -7,10 +7,13 @@ import { getSfLwcUsername, } from '../../../../scripts/lib/secrets.ts' -export type SalesforceApp = 'lwc' | 'experience-cloud' +export type SalesforceApp = 'lwc' | 'experience-cloud' | 'experience-cloud-headmarkup' const salesforceHomePath = '/lightning/app/c__SF_LWC_App/page/home' -const experienceSitePath = '/sfexperiencecloud/' +const experienceSitePaths: Record, string> = { + 'experience-cloud': '/sfexperiencecloud/', + 'experience-cloud-headmarkup': '/sfexperienceheadmarkup/', +} let salesforceLwcSession: Promise | undefined @@ -20,7 +23,7 @@ export interface SalesforceLwcSession { } export async function buildSalesforceUrl(app: SalesforceApp): Promise { - return app === 'lwc' ? await buildSalesforceLwcUrl() : buildSalesforceExperienceUrl() + return app === 'lwc' ? await buildSalesforceLwcUrl() : buildSalesforceExperienceUrl(app) } export function getSalesforceLwcSession(): Promise { @@ -93,7 +96,7 @@ async function getAccessToken( // Unlike the Lightning app, the Experience Cloud site is public, so we don't need to // authenticate or exchange a frontdoor token: we can derive the site URL directly from the // org's instance URL. -function buildSalesforceExperienceUrl(): string { +function buildSalesforceExperienceUrl(app: Exclude): string { const instanceUrl = getSfLwcInstanceUrl() if (!instanceUrl) { console.error('Salesforce credentials are not set') @@ -101,5 +104,5 @@ function buildSalesforceExperienceUrl(): string { } const siteDomain = instanceUrl.replace('.my.salesforce.com', '.my.site.com') - return `${siteDomain}${experienceSitePath}` + return `${siteDomain}${experienceSitePaths[app]}` } diff --git a/test/e2e/lib/framework/pageSetups.ts b/test/e2e/lib/framework/pageSetups.ts index d939baf4cf..d2db06c81c 100644 --- a/test/e2e/lib/framework/pageSetups.ts +++ b/test/e2e/lib/framework/pageSetups.ts @@ -304,15 +304,23 @@ export function microfrontendSetup(options: SetupOptions, servers: Servers) { }) } +const salesforceAppDirectories: Record = { + lwc: 'sf-lwc-app', + 'experience-cloud': 'sf-experience-app', + 'experience-cloud-headmarkup': 'sf-experience-headmarkup-app', +} + // Salesforce apps don't serve a locally-generated page body; this factory only drives the // page-side setup needed to init RUM on the remote Salesforce page. export async function salesforceSetup(options: SetupOptions, servers: Servers, page: Page): Promise { - const salesforceAppDirectory = options.salesforceApp === 'experience-cloud' ? 'sf-experience-app' : 'sf-lwc-app' + if (!options.salesforceApp) { + throw new Error('Salesforce app is not set') + } + const salesforceAppDirectory = salesforceAppDirectories[options.salesforceApp] const salesforceBundlePath = resolve( __dirname, `../../../apps/${salesforceAppDirectory}/force-app/main/default/staticresources/datadog_rum_salesforce.js` ) - await page.route(/\/resource(?:\/[^/?#]+)?\/datadog_rum_salesforce(?:\.js)?(?:[/?#].*)?$/, async (route) => { await route.fulfill({ body: await readFile(salesforceBundlePath), @@ -332,9 +340,9 @@ export async function salesforceSetup(options: SetupOptions, servers: Servers, p } if (options.rum) { - // Both sf-lwc-app and sf-experience-app have a committed datadogInit LWC that reads - // these globals and calls DD_RUM.init. On experience-cloud, that component only runs - // when the page is loaded with init=true. + // The LWC and standard Experience Cloud apps have a committed datadogInit LWC that reads + // these globals and calls DD_RUM.init. The Head Markup site reads the same globals from its + // configured head markup instead. await page.addInitScript( `window.RUM_CONFIGURATION = ${formatConfiguration(options.rum, servers)} window.RUM_CONTEXT = ${JSON.stringify(options.context)}` diff --git a/test/e2e/scenario/salesforce.scenario.ts b/test/e2e/scenario/salesforce.scenario.ts index dfcb9ddee4..751bcb9eb7 100644 --- a/test/e2e/scenario/salesforce.scenario.ts +++ b/test/e2e/scenario/salesforce.scenario.ts @@ -17,9 +17,11 @@ const baseSalesforceRumConfiguration = { trackUserInteractions: true, } -const salesforceApps: SalesforceApp[] = ['lwc', 'experience-cloud'] +const salesforceApps: SalesforceApp[] = ['lwc', 'experience-cloud', 'experience-cloud-headmarkup'] for (const app of salesforceApps) { + const canCallRumFromComponents = app !== 'experience-cloud-headmarkup' + createTest(`salesforce ${app} views`) .withRum(baseSalesforceRumConfiguration) .withSalesforceApp(app) @@ -59,19 +61,23 @@ for (const app of salesforceApps) { } }) - createTest(`salesforce ${app} long tasks and vitals`) + createTest(`salesforce ${app} long tasks${canCallRumFromComponents ? ' and vitals' : ''}`) .withRum(baseSalesforceRumConfiguration) .withSalesforceApp(app) .run(async ({ page, intakeRegistry, flushEvents }) => { await expect(page.getByTestId('home-custom-actions')).toBeVisible({ timeout: 30000 }) await page.getByTestId('long-task').click() - await page.getByRole('button', { name: 'Add Duration Vital' }).click() + if (canCallRumFromComponents) { + await page.getByRole('button', { name: 'Add Duration Vital' }).click() + } await flushEvents() expect(intakeRegistry.rumLongTaskEvents.length).toBeGreaterThanOrEqual(1) - expect(intakeRegistry.rumVitalEvents.length).toBeGreaterThanOrEqual(1) + if (canCallRumFromComponents) { + expect(intakeRegistry.rumVitalEvents.length).toBeGreaterThanOrEqual(1) + } }) createTest(`salesforce ${app} actions`) @@ -87,10 +93,12 @@ for (const app of salesforceApps) { const actionTypes = new Set(intakeRegistry.rumActionEvents.map((e) => e.action.type)) expect(actionTypes.has('click')).toBe(true) - const customAction = intakeRegistry.rumActionEvents.find( - (e) => e.action.type === 'custom' && e.action.target?.name?.includes('custom action 1') === true - ) - expect(customAction).toBeDefined() + if (canCallRumFromComponents) { + const customAction = intakeRegistry.rumActionEvents.find( + (e) => e.action.type === 'custom' && e.action.target?.name?.includes('custom action 1') === true + ) + expect(customAction).toBeDefined() + } }) createTest(`salesforce ${app} errors`) @@ -99,7 +107,9 @@ for (const app of salesforceApps) { .run(async ({ page, intakeRegistry, flushEvents, withBrowserLogs }) => { await expect(page.getByTestId('home-custom-actions')).toBeVisible({ timeout: 30000 }) - await page.getByTestId('custom-error-1').click() + if (canCallRumFromComponents) { + await page.getByTestId('custom-error-1').click() + } await page.getByTestId('runtime-error').click() await page.evaluate(() => window.console.error('salesforce console error test')) @@ -119,9 +129,11 @@ for (const app of salesforceApps) { ) expect(errorEvent).toBeDefined() - const customError = intakeRegistry.rumErrorEvents.find( - (e) => e.error.source === 'custom' && e.error.message?.includes('custom error 1') === true - ) - expect(customError).toBeDefined() + if (canCallRumFromComponents) { + const customError = intakeRegistry.rumErrorEvents.find( + (e) => e.error.source === 'custom' && e.error.message?.includes('custom error 1') === true + ) + expect(customError).toBeDefined() + } }) }