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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions scripts/build/build-test-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<void>
) {
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) =>
Expand Down
34 changes: 22 additions & 12 deletions scripts/salesforce-apps.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<AppKey, { dir: string; url: string; siteName?: string }> = {
lwc: {
Expand All @@ -26,6 +24,19 @@ const APPS: Record<AppKey, { dir: string; url: string; siteName?: string }> = {
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',
},
}

// 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<AppKey, string> = {
lwc: 'sf-lwc-app',
'experience-cloud': 'sf-experience-app',
'experience-cloud-headmarkup': 'sf-experience-headmarkup-app',
}

const SUPPORTED_COMMANDS = ['deploy-apps', 'get-urls']
Expand Down Expand Up @@ -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 })

Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions test/apps/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
67 changes: 0 additions & 67 deletions test/apps/sf-experience-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?xml version="1.0" encoding="UTF-8" ?>
<StaticResource xmlns="http://soap.sforce.com/2006/04/metadata">
<cacheControl>Public</cacheControl>
<contentType>application/javascript</contentType>
</StaticResource>
```

### 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
<template></template>
```

Component metadata exposes the bundle to Experience Builder:

```xml
<targets>
<target>lightningCommunity__Page</target>
<target>lightningCommunity__Default</target>
</targets>
```

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`:
Expand Down
13 changes: 8 additions & 5 deletions test/e2e/lib/framework/buildSalesforceUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exclude<SalesforceApp, 'lwc'>, string> = {
'experience-cloud': '/sfexperiencecloud/',
'experience-cloud-headmarkup': '/sfexperienceheadmarkup/',
}

let salesforceLwcSession: Promise<SalesforceLwcSession> | undefined

Expand All @@ -20,7 +23,7 @@ export interface SalesforceLwcSession {
}

export async function buildSalesforceUrl(app: SalesforceApp): Promise<string> {
return app === 'lwc' ? await buildSalesforceLwcUrl() : buildSalesforceExperienceUrl()
return app === 'lwc' ? await buildSalesforceLwcUrl() : buildSalesforceExperienceUrl(app)
}

export function getSalesforceLwcSession(): Promise<SalesforceLwcSession> {
Expand Down Expand Up @@ -93,13 +96,13 @@ 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<SalesforceApp, 'lwc'>): string {
const instanceUrl = getSfLwcInstanceUrl()
if (!instanceUrl) {
console.error('Salesforce credentials are not set')
return ''
}

const siteDomain = instanceUrl.replace('.my.salesforce.com', '.my.site.com')
return `${siteDomain}${experienceSitePath}`
return `${siteDomain}${experienceSitePaths[app]}`
}
18 changes: 13 additions & 5 deletions test/e2e/lib/framework/pageSetups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,15 +304,23 @@ export function microfrontendSetup(options: SetupOptions, servers: Servers) {
})
}

const salesforceAppDirectories: Record<SalesforceApp, string> = {
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<string> {
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),
Expand All @@ -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)}`
Expand Down
38 changes: 25 additions & 13 deletions test/e2e/scenario/salesforce.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`)
Expand All @@ -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`)
Expand All @@ -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'))

Expand All @@ -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()
}
})
}
Loading