diff --git a/package.json b/package.json index 52d0d958ff..a38a2a74b4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:e2e:init": "yarn build && yarn build:apps && yarn playwright install chromium --with-deps", "test:e2e": "playwright test --config test/e2e/playwright.local.config.ts --project chromium", "test:e2e:bs": "node --env-file-if-exists=.env ./scripts/test/bs-wrapper.ts playwright test --config test/e2e/playwright.bs.config.ts", + "test:e2e:salesforce": "playwright test --config test/e2e/playwright.salesforce.config.ts", "test:e2e:ci": "yarn test:e2e:init && yarn test:e2e", "test:e2e:ci:bs": "yarn build && yarn build:apps && yarn test:e2e:bs", "test:compat:tsc": "node scripts/check-typescript-compatibility.ts", diff --git a/packages/core/src/browser/addEventListener.spec.ts b/packages/core/src/browser/addEventListener.spec.ts index dc59626848..e7abeb784c 100644 --- a/packages/core/src/browser/addEventListener.spec.ts +++ b/packages/core/src/browser/addEventListener.spec.ts @@ -87,6 +87,31 @@ describe('addEventListener', () => { expect(customEventTarget.removeEventListener).toHaveBeenCalled() }) + it('does not break stop() when removeEventListener is missing', () => { + const addEventListenerSpy = jasmine.createSpy() + const customEventTarget = { + addEventListener: addEventListenerSpy, + } as unknown as HTMLElement + + const { stop } = addEventListener({ allowUntrustedEvents: false }, customEventTarget, 'change', noop) + + expect(addEventListenerSpy).toHaveBeenCalled() + expect(stop).not.toThrow() + }) + + it('skips registration when addEventListener is missing', () => { + const listener = jasmine.createSpy() + const removeEventListenerSpy = jasmine.createSpy() + const customEventTarget = { + removeEventListener: removeEventListenerSpy, + } as unknown as HTMLElement + + const { stop } = addEventListener({ allowUntrustedEvents: false }, customEventTarget, 'change', listener) + + expect(stop).not.toThrow() + expect(removeEventListenerSpy).not.toHaveBeenCalled() + }) + describe('Untrusted event', () => { beforeEach(() => { configuration = { allowUntrustedEvents: false } as Configuration diff --git a/packages/core/src/browser/addEventListener.ts b/packages/core/src/browser/addEventListener.ts index 6fb2c43ba5..2ef7b0bf30 100644 --- a/packages/core/src/browser/addEventListener.ts +++ b/packages/core/src/browser/addEventListener.ts @@ -1,5 +1,6 @@ import { monitor } from '../tools/monitor' import { getZoneJsOriginalValue } from '../tools/getZoneJsOriginalValue' +import { noop } from '../tools/utils/functionUtils' import type { CookieStore, CookieStoreEventMap, VisualViewport, VisualViewportEventMap } from './browser.types' export type TrustableEvent = E & { __ddIsTrusted?: boolean } @@ -132,10 +133,20 @@ export function addEventListeners add.call(eventTarget, eventName, listenerWithMonitor, options)) function stop() { const remove = getZoneJsOriginalValue(listenerTarget, 'removeEventListener') + if (typeof remove !== 'function') { + return + } + eventNames.forEach((eventName) => remove.call(eventTarget, eventName, listenerWithMonitor, options)) } diff --git a/packages/core/src/domain/report/reportObservable.spec.ts b/packages/core/src/domain/report/reportObservable.spec.ts index 2d8c4ecd6b..54ed64249a 100644 --- a/packages/core/src/domain/report/reportObservable.spec.ts +++ b/packages/core/src/domain/report/reportObservable.spec.ts @@ -68,4 +68,18 @@ describe('report observable', () => { csp: { disposition: 'enforce' }, }) }) + + it(`should ignore ${RawReportType.cspViolation} when the environment rejects the event listener`, () => { + ;(EventTarget.prototype.addEventListener as jasmine.Spy).and.callFake((type: string) => { + if (type === 'securitypolicyviolation') { + throw new Error('unsupported event listener') + } + }) + + expect(() => { + consoleSubscription = initReportObservable(configuration, [RawReportType.cspViolation]).subscribe(notifyReport) + }).not.toThrow() + + expect(notifyReport).not.toHaveBeenCalled() + }) }) diff --git a/packages/core/src/domain/report/reportObservable.ts b/packages/core/src/domain/report/reportObservable.ts index 282c5fc935..566eccee4d 100644 --- a/packages/core/src/domain/report/reportObservable.ts +++ b/packages/core/src/domain/report/reportObservable.ts @@ -60,11 +60,15 @@ function createReportObservable(reportTypes: ReportType[]) { function createCspViolationReportObservable(configuration: Configuration) { return new Observable((observable) => { - const { stop } = addEventListener(configuration, document, DOM_EVENT.SECURITY_POLICY_VIOLATION, (event) => { - observable.notify(buildRawReportErrorFromCspViolation(event)) - }) + try { + const { stop } = addEventListener(configuration, document, DOM_EVENT.SECURITY_POLICY_VIOLATION, (event) => { + observable.notify(buildRawReportErrorFromCspViolation(event)) + }) - return stop + return stop + } catch { + return + } }) } diff --git a/packages/core/src/tools/globalObject.spec.ts b/packages/core/src/tools/globalObject.spec.ts new file mode 100644 index 0000000000..edc4c8b37a --- /dev/null +++ b/packages/core/src/tools/globalObject.spec.ts @@ -0,0 +1,70 @@ +import { getGlobalObject } from './globalObject' + +describe('getGlobalObject', () => { + it('returns self when globalThis is unavailable', () => { + const globalThisDescriptor = Object.getOwnPropertyDescriptor(window, 'globalThis') + const selfDescriptor = Object.getOwnPropertyDescriptor(window, 'self') + + if (!globalThisDescriptor?.configurable) { + pending('globalThis descriptor is not configurable in this environment') + } + if (!selfDescriptor?.configurable) { + pending('self descriptor is not configurable in this environment') + } + + const fakeSelf = { dd: 'sandbox-global' } + + Object.defineProperty(window, 'globalThis', { + value: undefined, + configurable: true, + writable: true, + }) + Object.defineProperty(window, 'self', { + value: fakeSelf, + configurable: true, + writable: true, + }) + + try { + expect(getGlobalObject()).toBe(fakeSelf) + } finally { + Object.defineProperty(window, 'globalThis', globalThisDescriptor!) + Object.defineProperty(window, 'self', selfDescriptor!) + } + }) + + it('returns self without relying on the Object.prototype fallback when globalThis is unavailable', () => { + const globalThisDescriptor = Object.getOwnPropertyDescriptor(window, 'globalThis') + const selfDescriptor = Object.getOwnPropertyDescriptor(window, 'self') + + if (!globalThisDescriptor?.configurable) { + pending('globalThis descriptor is not configurable in this environment') + } + if (!selfDescriptor?.configurable) { + pending('self descriptor is not configurable in this environment') + } + + const fakeSelf = { dd: 'sandbox-global' } + + Object.defineProperty(window, 'globalThis', { + value: undefined, + configurable: true, + writable: true, + }) + Object.defineProperty(window, 'self', { + value: fakeSelf, + configurable: true, + writable: true, + }) + + const definePropertySpy = spyOn(Object, 'defineProperty').and.callThrough() + + try { + expect(getGlobalObject()).toBe(fakeSelf) + expect(definePropertySpy).not.toHaveBeenCalledWith(Object.prototype, '_dd_temp_', jasmine.any(Object)) + } finally { + Object.defineProperty(window, 'globalThis', globalThisDescriptor!) + Object.defineProperty(window, 'self', selfDescriptor!) + } + }) +}) diff --git a/packages/core/src/tools/globalObject.ts b/packages/core/src/tools/globalObject.ts index ce9b637c02..fe97bc8ca0 100644 --- a/packages/core/src/tools/globalObject.ts +++ b/packages/core/src/tools/globalObject.ts @@ -17,27 +17,39 @@ export function getGlobalObject(): T { if (typeof globalThis === 'object') { return globalThis as unknown as T } - Object.defineProperty(Object.prototype, '_dd_temp_', { - get() { - return this as object - }, - configurable: true, - }) - // @ts-ignore _dd_temp is defined using defineProperty - let globalObject: unknown = _dd_temp_ - // @ts-ignore _dd_temp is defined using defineProperty - delete Object.prototype._dd_temp_ + + // Under Lightning Web Security, third-party code should rely on `self` to + // access the sandbox global object. The Object.prototype fallback below can + // also fail there because Object.prototype is sealed. + if (typeof self === 'object') { + return self as unknown as T + } + + if (typeof window === 'object') { + return window as unknown as T + } + + let globalObject: unknown + + try { + Object.defineProperty(Object.prototype, '_dd_temp_', { + get() { + return this as object + }, + configurable: true, + }) + // @ts-ignore _dd_temp is defined using defineProperty + globalObject = _dd_temp_ + // @ts-ignore _dd_temp is defined using defineProperty + delete Object.prototype._dd_temp_ + } catch { + globalObject = {} + } + if (typeof globalObject !== 'object') { - // on safari _dd_temp_ is available on window but not globally - // fallback on other browser globals check - if (typeof self === 'object') { - globalObject = self - } else if (typeof window === 'object') { - globalObject = window - } else { - globalObject = {} - } + globalObject = {} } + return globalObject as T } diff --git a/packages/core/src/tools/instrumentMethod.spec.ts b/packages/core/src/tools/instrumentMethod.spec.ts index 097f665bc4..826076f328 100644 --- a/packages/core/src/tools/instrumentMethod.spec.ts +++ b/packages/core/src/tools/instrumentMethod.spec.ts @@ -57,6 +57,43 @@ describe('instrumentMethod', () => { expect('onevent' in object).toBeFalse() }) + it('skips instrumentation on readonly methods', () => { + const originalMethod = () => 1 + const object = {} as { method: () => number } + Object.defineProperty(object, 'method', { + value: originalMethod, + writable: false, + configurable: true, + }) + + const instrumentationSpy = jasmine.createSpy() + const { stop } = instrumentMethod(object, 'method', instrumentationSpy) + + expect(object.method).toBe(originalMethod) + expect(object.method()).toBe(1) + expect(instrumentationSpy).not.toHaveBeenCalled() + expect(stop).not.toThrow() + }) + + it('skips instrumentation on readonly methods defined on the prototype chain', () => { + const originalMethod = jasmine.createSpy().and.returnValue(1) + const prototype = {} as { method: () => number } + Object.defineProperty(prototype, 'method', { + value: originalMethod, + writable: false, + configurable: true, + }) + const object = Object.create(prototype) as { method: () => number } + + const instrumentationSpy = jasmine.createSpy() + const { stop } = instrumentMethod(object, 'method', instrumentationSpy) + + expect(object.method()).toBe(1) + expect(instrumentationSpy).not.toHaveBeenCalled() + expect(Object.prototype.hasOwnProperty.call(object, 'method')).toBeFalse() + expect(stop).not.toThrow() + }) + it('calls the instrumentation with method target and parameters', () => { const object = { method: (a: number, b: number) => a + b } const instrumentationSpy = jasmine.createSpy<(call: InstrumentedMethodCall) => void>() diff --git a/packages/core/src/tools/instrumentMethod.ts b/packages/core/src/tools/instrumentMethod.ts index 5d5a398824..92641af433 100644 --- a/packages/core/src/tools/instrumentMethod.ts +++ b/packages/core/src/tools/instrumentMethod.ts @@ -73,6 +73,11 @@ export function instrumentMethod) => void, { computeHandlingStack }: { computeHandlingStack?: boolean } = {} ) { + const methodDescriptor = findDescriptorInPrototypeChain(targetPrototype, method) + if (methodDescriptor && !canAssignDescriptor(methodDescriptor)) { + return { stop: noop } + } + let original = targetPrototype[method] if (typeof original !== 'function') { @@ -117,14 +122,22 @@ export function instrumentMethod { stopped = true // If the instrumentation has been removed by a third party, keep the last one if (targetPrototype[method] === instrumentation) { - targetPrototype[method] = original + try { + targetPrototype[method] = original + } catch { + // Ignore restore failures on readonly properties. + } } }, } @@ -168,3 +181,25 @@ export function instrumentSetter { expect(cookieChanges).toEqual(['foo', 'bar']) }) + + it('should fallback to polling when cookieStore rejects change listeners', () => { + Object.defineProperty(window, 'cookieStore', { + configurable: true, + get: () => ({ + addEventListener: () => { + throw new Error("Lightning Web Security: Cannot add 'change' event listener to CookieStore object.") + }, + removeEventListener: () => undefined, + }), + }) + const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME) + + let cookieChange: string | undefined + expect(() => { + subscription = observable.subscribe((change) => (cookieChange = change)) + }).not.toThrow() + + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + + expect(cookieChange).toEqual('foo') + }) }) diff --git a/packages/rum-core/src/browser/cookieObservable.ts b/packages/rum-core/src/browser/cookieObservable.ts index 3bb3080d19..128a5eea0e 100644 --- a/packages/rum-core/src/browser/cookieObservable.ts +++ b/packages/rum-core/src/browser/cookieObservable.ts @@ -27,22 +27,27 @@ export function createCookieObservable(configuration: Configuration, cookieName: function listenToCookieStoreChange(configuration: Configuration) { return (cookieName: string, callback: (event: string | undefined) => void) => { - const listener = addEventListener( - configuration, - (window as CookieStoreWindow).cookieStore!, - DOM_EVENT.CHANGE, - (event) => { - // Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays. - // However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226 - const changeEvent = - event.changed.find((event) => event.name === cookieName) || - event.deleted.find((event) => event.name === cookieName) - if (changeEvent) { - callback(changeEvent.value) + try { + const listener = addEventListener( + configuration, + (window as CookieStoreWindow).cookieStore!, + DOM_EVENT.CHANGE, + (event) => { + // Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays. + // However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226 + const changeEvent = + event.changed.find((event) => event.name === cookieName) || + event.deleted.find((event) => event.name === cookieName) + if (changeEvent) { + callback(changeEvent.value) + } } - } - ) - return listener.stop + ) + return listener.stop + } catch { + // Some runtimes expose cookieStore but reject event listeners (for example under sandboxed security layers). + return watchCookieFallback(cookieName, callback) + } } } diff --git a/packages/rum-core/src/domain/contexts/urlContexts.lws.spec.ts b/packages/rum-core/src/domain/contexts/urlContexts.lws.spec.ts new file mode 100644 index 0000000000..6c210148f9 --- /dev/null +++ b/packages/rum-core/src/domain/contexts/urlContexts.lws.spec.ts @@ -0,0 +1,35 @@ +import { clocksOrigin, Observable } from '@datadog/browser-core' +import { registerCleanupTask, replaceMockable } from '@datadog/browser-core/test' +import type { LocationChange } from '../../browser/locationChangeObservable' +import { LifeCycle, LifeCycleEventType } from '../lifeCycle' +import type { ViewCreatedEvent } from '../view/trackViews' +import { createHooks } from '../hooks' +import { startUrlContexts } from './urlContexts' + +describe('urlContexts LWS compatibility', () => { + it('should use the provided view url when global location is unavailable', () => { + const lifeCycle = new LifeCycle() + const hooks = createHooks() + const locationChangeObservable = new Observable() + const originalLocation = window.location + + replaceMockable(originalLocation, undefined as unknown as Location) + + const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable) + registerCleanupTask(() => { + urlContexts.stop() + }) + + expect(() => { + lifeCycle.notify(LifeCycleEventType.BEFORE_VIEW_CREATED, { + startClocks: clocksOrigin(), + url: 'https://example.com/lightning/page/home', + } as ViewCreatedEvent) + }).not.toThrow() + + expect(urlContexts.findUrl()).toEqual({ + url: 'https://example.com/lightning/page/home', + referrer: document.referrer, + }) + }) +}) diff --git a/packages/rum-core/src/domain/contexts/urlContexts.ts b/packages/rum-core/src/domain/contexts/urlContexts.ts index 530172a84c..c33875ffed 100644 --- a/packages/rum-core/src/domain/contexts/urlContexts.ts +++ b/packages/rum-core/src/domain/contexts/urlContexts.ts @@ -7,6 +7,7 @@ import { DISCARDED, mockable, buildUrl, + getGlobalObject, } from '@datadog/browser-core' import type { LocationChange } from '../../browser/locationChangeObservable' import type { LifeCycle } from '../lifeCycle' @@ -41,8 +42,11 @@ export function startUrlContexts( let previousViewUrl: string | undefined lifeCycle.subscribe(LifeCycleEventType.BEFORE_VIEW_CREATED, ({ startClocks, url }) => { - const locationHref = mockable(location).href + const locationHref = mockable(getGlobalObject().location)?.href const viewUrl = url !== undefined ? buildUrl(url, locationHref).href : locationHref + if (!viewUrl) { + return + } urlContextHistory.add( buildUrlContext({ url: viewUrl, diff --git a/packages/rum/package.json b/packages/rum/package.json index f0689c7ee1..a693d74f0f 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -18,6 +18,7 @@ "scripts": { "build": "node ../../scripts/build/build-package.ts --modules --bundle datadog-rum.js", "build:bundle": "node ../../scripts/build/build-package.ts --bundle datadog-rum.js", + "build:salesforce": "node ../../scripts/build/build-package.ts --bundle datadog-rum-salesforce.js --entry ./src/entries/salesforce.ts", "prepack": "yarn build" }, "dependencies": { diff --git a/packages/rum/src/domain/salesforce/initConfiguration.spec.ts b/packages/rum/src/domain/salesforce/initConfiguration.spec.ts new file mode 100644 index 0000000000..b716ffe4e4 --- /dev/null +++ b/packages/rum/src/domain/salesforce/initConfiguration.spec.ts @@ -0,0 +1,44 @@ +import type { RumInitConfiguration } from '@datadog/browser-rum-core' +import { buildSalesforceInitConfiguration } from './initConfiguration' + +describe('salesforce init configuration', () => { + it('forces manual view tracking', () => { + const initConfiguration = buildSalesforceInitConfiguration({ + applicationId: 'app-id', + clientToken: 'client-token', + trackViewsManually: false, + } as RumInitConfiguration) + + expect(initConfiguration.trackViewsManually).toBeTrue() + }) + + it('preserves customer configuration unrelated to view tracking mode', () => { + const initConfiguration = buildSalesforceInitConfiguration({ + applicationId: 'app-id', + clientToken: 'client-token', + service: 'browser-sdk-sandbox', + env: 'dev', + site: 'datadoghq.com', + trackResources: false, + trackUserInteractions: false, + trackLongTasks: false, + sessionReplaySampleRate: 0, + profilingSampleRate: 0, + } as RumInitConfiguration) + + expect(initConfiguration).toEqual( + jasmine.objectContaining({ + applicationId: 'app-id', + clientToken: 'client-token', + service: 'browser-sdk-sandbox', + env: 'dev', + site: 'datadoghq.com', + trackResources: false, + trackUserInteractions: false, + trackLongTasks: false, + sessionReplaySampleRate: 0, + profilingSampleRate: 0, + }) + ) + }) +}) diff --git a/packages/rum/src/domain/salesforce/initConfiguration.ts b/packages/rum/src/domain/salesforce/initConfiguration.ts new file mode 100644 index 0000000000..aa6f89bc7a --- /dev/null +++ b/packages/rum/src/domain/salesforce/initConfiguration.ts @@ -0,0 +1,12 @@ +import type { RumInitConfiguration } from '@datadog/browser-rum-core' + +const SALESFORCE_INIT_DEFAULTS: Pick = { + trackViewsManually: true, +} + +export function buildSalesforceInitConfiguration(initConfiguration: RumInitConfiguration): RumInitConfiguration { + return { + ...initConfiguration, + ...SALESFORCE_INIT_DEFAULTS, + } +} diff --git a/packages/rum/src/domain/salesforce/viewTracker.spec.ts b/packages/rum/src/domain/salesforce/viewTracker.spec.ts new file mode 100644 index 0000000000..e1bb3bf7c2 --- /dev/null +++ b/packages/rum/src/domain/salesforce/viewTracker.spec.ts @@ -0,0 +1,108 @@ +import type { Clock } from '@datadog/browser-core/test' +import { mockClock } from '@datadog/browser-core/test' +import { startSalesforceViewTracking } from './viewTracker' + +describe('salesforce view tracker', () => { + let clock: Clock + let startView: jasmine.Spy + let location: { pathname?: string; href?: string } | undefined + + beforeEach(() => { + clock = mockClock() + startView = jasmine.createSpy() + location = { + pathname: '/lightning/page/home', + href: 'https://example.lightning.force.com/lightning/page/home', + } + }) + + it('starts the current Lightning view on bootstrap', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView }), + getLocation: () => location, + }) + + expect(startView).toHaveBeenCalledOnceWith({ + name: '/lightning/page/home', + url: 'https://example.lightning.force.com/lightning/page/home', + }) + }) + + it('does not duplicate the same pathname when only query string or hash changes', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView }), + getLocation: () => location, + pollInterval: 500, + }) + + location = { + pathname: '/lightning/page/home/', + href: 'https://example.lightning.force.com/lightning/page/home?foo=bar#hash', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledTimes(1) + }) + + it('starts a new view when polling detects a Lightning pathname change', () => { + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView }), + getLocation: () => location, + pollInterval: 500, + }) + + location = { + pathname: '/lightning/n/Product_Explorer', + href: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledTimes(2) + expect(startView.calls.argsFor(1)).toEqual([ + { + name: '/lightning/n/Product_Explorer', + url: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + }, + ]) + }) + + it('keeps polling when location is temporarily unavailable', () => { + location = undefined + + startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView }), + getLocation: () => location, + pollInterval: 500, + }) + + expect(startView).not.toHaveBeenCalled() + + location = { + pathname: '/lightning/page/home', + href: 'https://example.lightning.force.com/lightning/page/home', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledOnceWith({ + name: '/lightning/page/home', + url: 'https://example.lightning.force.com/lightning/page/home', + }) + }) + + it('stops polling', () => { + const subscription = startSalesforceViewTracking({ + getRumPublicApi: () => ({ startView }), + getLocation: () => location, + pollInterval: 500, + }) + + subscription.stop() + location = { + pathname: '/lightning/n/Product_Explorer', + href: 'https://example.lightning.force.com/lightning/n/Product_Explorer', + } + clock.tick(500) + + expect(startView).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/rum/src/domain/salesforce/viewTracker.ts b/packages/rum/src/domain/salesforce/viewTracker.ts new file mode 100644 index 0000000000..d2ff43f007 --- /dev/null +++ b/packages/rum/src/domain/salesforce/viewTracker.ts @@ -0,0 +1,131 @@ +import { buildUrl, clearInterval, setInterval } from '@datadog/browser-core' +import type { TimeoutId } from '@datadog/browser-core' +import type { RumPublicApi, ViewOptions } from '@datadog/browser-rum-core' + +export interface SalesforceLocation { + pathname?: string + href?: string +} + +interface StartSalesforceViewTrackingOptions { + getRumPublicApi: () => Pick | undefined + getLocation?: () => SalesforceLocation | undefined + pollInterval?: number +} + +interface SalesforceView { + key: string + url?: string +} + +const DEFAULT_LOCATION_POLL_INTERVAL = 500 + +export function startSalesforceViewTracking(options: StartSalesforceViewTrackingOptions) { + const getLocation = options.getLocation ?? getNavigationLocation + const pollInterval = options.pollInterval ?? DEFAULT_LOCATION_POLL_INTERVAL + + let lastEmittedRouteKey: string | undefined + let pollIntervalId: TimeoutId | undefined + + trackCurrentView() + pollIntervalId = setInterval(trackCurrentView, pollInterval) + + function trackCurrentView() { + const currentView = resolveCurrentView(getLocation()) + + if (!currentView || currentView.key === lastEmittedRouteKey) { + return + } + + const rumPublicApi = options.getRumPublicApi() + + if (!rumPublicApi) { + return + } + + rumPublicApi.startView(toViewOptions(currentView)) + lastEmittedRouteKey = currentView.key + } + + return { + stop() { + clearInterval(pollIntervalId) + pollIntervalId = undefined + }, + } +} + +function getNavigationLocation(): SalesforceLocation | undefined { + try { + return { + href: window.location.href, + pathname: window.location.pathname, + } + } catch { + return undefined + } +} + +function resolveCurrentView(location: SalesforceLocation | undefined): SalesforceView | undefined { + if (!location) { + return undefined + } + + const url = normalizeLocationHref(location.href) + const key = normalizePathname(location.pathname) ?? getPathnameFromHref(url) + + if (!key) { + return undefined + } + + return { + key, + url, + } +} + +function toViewOptions(view: SalesforceView): ViewOptions { + return view.url ? { name: view.key, url: view.url } : { name: view.key } +} + +function getPathnameFromHref(href: string | undefined) { + if (!href) { + return undefined + } + + try { + return normalizePathname(buildUrl(href).pathname) + } catch { + return undefined + } +} + +function normalizePathname(pathname: unknown) { + if (typeof pathname !== 'string' || !pathname.trim()) { + return undefined + } + + let normalizedPathname = pathname.trim() + + if (!normalizedPathname.startsWith('/')) { + normalizedPathname = `/${normalizedPathname}` + } + + if (normalizedPathname.length > 1) { + normalizedPathname = normalizedPathname.replace(/\/+$/, '') + } + + return normalizedPathname || '/' +} + +function normalizeLocationHref(href: unknown) { + if (typeof href !== 'string' || !href.trim()) { + return undefined + } + + try { + return buildUrl(href).href + } catch { + return undefined + } +} diff --git a/packages/rum/src/entries/salesforce.ts b/packages/rum/src/entries/salesforce.ts new file mode 100644 index 0000000000..aeb06978fb --- /dev/null +++ b/packages/rum/src/entries/salesforce.ts @@ -0,0 +1,103 @@ +import { defineGlobal, getGlobalObject } from '@datadog/browser-core' +import type { RumInitConfiguration, RumPublicApi } from '@datadog/browser-rum-core' +import { makeRumPublicApi } from '@datadog/browser-rum-core' +import { makeProfilerApi } from '../boot/profilerApi' +import { makeRecorderApi } from '../boot/recorderApi' +import { createDeflateEncoder, startDeflateWorker } from '../domain/deflate' +import { buildSalesforceInitConfiguration } from '../domain/salesforce/initConfiguration' +import { startSalesforceViewTracking } from '../domain/salesforce/viewTracker' + +export type { + User, + Account, + TraceContextInjection, + SessionPersistence, + TrackingConsent, + MatchOption, + ProxyFn, + Site, + Context, + ContextValue, + ContextArray, + RumInternalContext, +} from '@datadog/browser-core' +export { DefaultPrivacyLevel } from '@datadog/browser-core' + +/** + * @deprecated Use {@link DatadogRum} instead + */ +export type RumGlobal = RumPublicApi + +export type { + RumPublicApi as DatadogRum, + RumInitConfiguration, + RumBeforeSend, + ViewOptions, + StartRecordingOptions, + AddDurationVitalOptions, + DurationVitalOptions, + DurationVitalReference, + TracingOption, + RumPlugin, + OnRumStartOptions, + PropagatorType, + FeatureFlagsForEvents, + MatchHeader, + CommonProperties, + RumEvent, + RumActionEvent, + RumErrorEvent, + RumLongTaskEvent, + RumResourceEvent, + RumViewEvent, + RumVitalEvent, + RumEventDomainContext, + RumViewEventDomainContext, + RumErrorEventDomainContext, + RumActionEventDomainContext, + RumVitalEventDomainContext, + RumFetchResourceEventDomainContext, + RumXhrResourceEventDomainContext, + RumOtherResourceEventDomainContext, + RumLongTaskEventDomainContext, +} from '@datadog/browser-rum-core' + +export { DEFAULT_TRACKED_RESOURCE_HEADERS } from '@datadog/browser-rum-core' + +const salesforceGlobal = getGlobalObject() + +const recorderApi = makeRecorderApi() + +const profilerApi = makeProfilerApi() + +export const datadogRum = createSalesforceDatadogRum( + makeRumPublicApi(recorderApi, profilerApi, { + startDeflateWorker, + createDeflateEncoder, + sdkName: 'rum', + }) +) + +interface BrowserWindow extends Window { + DD_RUM?: RumPublicApi +} + +defineGlobal(salesforceGlobal, 'DD_RUM', datadogRum) + +function createSalesforceDatadogRum(baseRum: RumPublicApi): RumPublicApi { + const baseInit = baseRum.init + let stopSalesforceViewTracking: (() => void) | undefined + + baseRum.init = (initConfiguration: RumInitConfiguration) => { + baseInit(buildSalesforceInitConfiguration(initConfiguration)) + + if (!stopSalesforceViewTracking) { + const salesforceViewTracking = startSalesforceViewTracking({ + getRumPublicApi: () => baseRum, + }) + stopSalesforceViewTracking = () => salesforceViewTracking.stop() + } + } + + return baseRum +} diff --git a/rum-events-format b/rum-events-format index 4f4ab2c504..5a80fb9a3c 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 4f4ab2c50456d688ae228904ced4c68aa382ae4c +Subproject commit 5a80fb9a3c054b28fba195fd301cadb094bccef8 diff --git a/scripts/build/build-package.ts b/scripts/build/build-package.ts index a60b5ff78e..d2435bc61c 100644 --- a/scripts/build/build-package.ts +++ b/scripts/build/build-package.ts @@ -19,6 +19,12 @@ runMain(async () => { bundle: { type: 'string', }, + entry: { + type: 'string', + }, + 'single-bundle': { + type: 'boolean', + }, verbose: { type: 'boolean', default: false, @@ -43,7 +49,9 @@ runMain(async () => { if (values.bundle) { printLog('Building bundle...') await buildBundle({ + entry: values.entry ?? './src/entries/main.ts', filename: values.bundle, + singleBundle: !!values['single-bundle'], verbose: values.verbose, }) } @@ -51,14 +59,25 @@ runMain(async () => { printLog('Done.') }) -async function buildBundle({ filename, verbose }: { filename: string; verbose: boolean }) { +async function buildBundle({ + entry, + filename, + singleBundle, + verbose, +}: { + entry: string + filename: string + singleBundle: boolean + verbose: boolean +}) { await fs.rm('./bundle', { recursive: true, force: true }) return new Promise((resolve, reject) => { webpack( webpackBase({ mode: 'production', - entry: './src/entries/main.ts', + entry, filename, + plugins: singleBundle ? [new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 })] : undefined, }), (error, stats) => { if (error) { diff --git a/test/e2e/lib/framework/index.ts b/test/e2e/lib/framework/index.ts index 5e275e1610..d847cf9b08 100644 --- a/test/e2e/lib/framework/index.ts +++ b/test/e2e/lib/framework/index.ts @@ -12,6 +12,7 @@ export { microfrontendSetup, } from './pageSetups' export { IntakeRegistry } from './intakeRegistry' +export { createIntakeProxyMiddleware } from './intakeProxyMiddleware' export { getTestServers, waitForServersIdle } from './httpServers' export { flushEvents } from './flushEvents' export { waitForRequests } from './waitForRequests' diff --git a/test/e2e/lib/framework/intakeProxyMiddleware.ts b/test/e2e/lib/framework/intakeProxyMiddleware.ts index 4cfc0dbf24..83b0a01135 100644 --- a/test/e2e/lib/framework/intakeProxyMiddleware.ts +++ b/test/e2e/lib/framework/intakeProxyMiddleware.ts @@ -62,16 +62,18 @@ interface IntakeRequestInfos { interface IntakeProxyOptions { onRequest?: (request: IntakeRequest) => void + forward?: boolean } export function createIntakeProxyMiddleware(options: IntakeProxyOptions): express.RequestHandler { return async (req, res) => { const infos = computeIntakeRequestInfos(req) + const shouldForward = options.forward ?? true try { const [intakeRequest] = await Promise.all([ readIntakeRequest(req, infos), - !infos.isBridge && forwardIntakeRequestToDatadog(req), + shouldForward && !infos.isBridge && forwardIntakeRequestToDatadog(req), ]) options.onRequest?.(intakeRequest) } catch (error) { diff --git a/test/e2e/playwright.salesforce.config.ts b/test/e2e/playwright.salesforce.config.ts new file mode 100644 index 0000000000..001f82d063 --- /dev/null +++ b/test/e2e/playwright.salesforce.config.ts @@ -0,0 +1,51 @@ +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +const lightningStorageState = path.resolve(__dirname, 'test-results/.auth/salesforce-lightning.json') + +// eslint-disable-next-line import/no-default-export +export default defineConfig({ + testDir: './salesforce', + testMatch: ['**/*.spec.ts'], + tsconfig: './tsconfig.json', + fullyParallel: false, + timeout: 60_000, + workers: 1, + reporter: [['line'], ['./noticeReporter.ts'], ['html']], + use: { + // So we can send to the intake from the Salesforce Domain. + bypassCSP: true, + // We'll ignore HTTPS errors since we're using self-signed certificates. + ignoreHTTPSErrors: true, + // So we can send to the intake from the Salesforce Domain. + permissions: ['local-network-access'], + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'setup', + testMatch: ['**/auth.setup.ts'], + use: { + ...devices['Desktop Chrome'], + trace: 'off', + screenshot: 'off', + video: 'off', + }, + }, + { + name: 'experience-chromium', + dependencies: ['setup'], + testMatch: ['**/experienceCloud.spec.ts'], + use: devices['Desktop Chrome'], + }, + { + name: 'lightning-chromium', + dependencies: ['setup'], + testMatch: ['**/lightningExperience.spec.ts'], + use: { + ...devices['Desktop Chrome'], + storageState: lightningStorageState, + }, + }, + ], +}) diff --git a/test/e2e/salesforce/auth.setup.ts b/test/e2e/salesforce/auth.setup.ts new file mode 100644 index 0000000000..5193117462 --- /dev/null +++ b/test/e2e/salesforce/auth.setup.ts @@ -0,0 +1,15 @@ +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { test as setup } from '@playwright/test' +import { getSalesforceTargets } from './support/salesforceTargets' + +const authDirectory = path.resolve(__dirname, '../test-results/.auth') +const lightningStorageState = path.join(authDirectory, 'salesforce-lightning.json') + +setup('authenticate Lightning Experience via sf org open', async ({ page }) => { + const { loginUrl } = getSalesforceTargets() + await page.goto(loginUrl, { waitUntil: 'commit' }) + await page.waitForURL('**/lightning/page/home', { timeout: 30_000 }) + mkdirSync(authDirectory, { recursive: true }) + await page.context().storageState({ path: lightningStorageState }) +}) diff --git a/test/e2e/salesforce/experienceCloud.spec.ts b/test/e2e/salesforce/experienceCloud.spec.ts new file mode 100644 index 0000000000..5f754c3544 --- /dev/null +++ b/test/e2e/salesforce/experienceCloud.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' +import { + flushSalesforceRumEvents, + installSalesforceRumProxy, + startSalesforceIntakeProxy, + waitForRumProxyInitialization, +} from './support/salesforceIntakeProxy' +import { getSalesforceTargets } from './support/salesforceTargets' + +test('experience cloud emits an initial home view and a route-change Product Explorer view', async ({ page }) => { + const targets = getSalesforceTargets() + const intakeProxy = await startSalesforceIntakeProxy() + const productExplorerContent = page.getByText('DYNAMO X1') + + try { + await installSalesforceRumProxy(page.context(), intakeProxy.origin) + await page.goto(targets.experienceUrl, { waitUntil: 'domcontentloaded' }) + await waitForRumProxyInitialization(page, intakeProxy.origin) + const productExplorerLink = page.getByRole('link', { name: 'Product Explorer' }) + + await expect(productExplorerLink).toBeVisible() + await expect(page).toHaveURL(/\/ebikes\/s\/?$/) + + await productExplorerLink.click() + await expect(page).toHaveURL(targets.experienceProductExplorerUrl) + await expect(productExplorerContent).toBeVisible() + + await flushSalesforceRumEvents(page) + + await intakeProxy.waitForViews([ + { path: '/ebikes/s', loadingType: 'initial_load' }, + { path: '/ebikes/s/product-explorer', loadingType: 'route_change' }, + ]) + } finally { + await intakeProxy.stop() + } +}) diff --git a/test/e2e/salesforce/lightningExperience.spec.ts b/test/e2e/salesforce/lightningExperience.spec.ts new file mode 100644 index 0000000000..deeeb207c6 --- /dev/null +++ b/test/e2e/salesforce/lightningExperience.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' +import { + flushSalesforceRumEvents, + installSalesforceRumProxy, + startSalesforceIntakeProxy, + waitForRumProxyInitialization, +} from './support/salesforceIntakeProxy' +import { getSalesforceTargets } from './support/salesforceTargets' + +test('lightning experience emits an initial home view and a route-change Product Explorer view', async ({ page }) => { + const targets = getSalesforceTargets() + const intakeProxy = await startSalesforceIntakeProxy() + const productExplorerContent = page.getByText('DYNAMO X1') + + try { + await installSalesforceRumProxy(page.context(), intakeProxy.origin) + await page.goto(targets.lightningHomeUrl, { waitUntil: 'domcontentloaded' }) + await waitForRumProxyInitialization(page, intakeProxy.origin) + const productExplorerLink = page.getByRole('link', { name: 'Product Explorer' }) + + await expect(productExplorerLink).toBeVisible() + await expect(page).toHaveURL(targets.lightningHomeUrl) + + await productExplorerLink.click() + await expect(page).toHaveURL(targets.lightningProductExplorerUrl) + await expect(productExplorerContent).toBeVisible() + + await flushSalesforceRumEvents(page) + + await intakeProxy.waitForViews([ + { path: '/lightning/page/home', loadingType: 'initial_load' }, + { path: '/lightning/n/Product_Explorer', loadingType: 'route_change' }, + ]) + } finally { + await intakeProxy.stop() + } +}) diff --git a/test/e2e/salesforce/support/salesforceIntakeProxy.ts b/test/e2e/salesforce/support/salesforceIntakeProxy.ts new file mode 100644 index 0000000000..6504d9491b --- /dev/null +++ b/test/e2e/salesforce/support/salesforceIntakeProxy.ts @@ -0,0 +1,346 @@ +import https from 'node:https' +import type http from 'node:http' +import type { AddressInfo } from 'node:net' +import express from 'express' +import forge from 'node-forge' +import type { BrowserContext, Page } from '@playwright/test' +import { createIntakeProxyMiddleware, IntakeRegistry } from '../../lib/framework' + +const SALESFORCE_INTAKE_PROXY_PORT = 9242 +const SALESFORCE_INTAKE_PROXY_IDLE_DELAY = 200 +const SALESFORCE_INTAKE_PROXY_CLOSE_DELAY = 1_000 + +export interface ExpectedSalesforceRumView { + path: string + loadingType: string +} + +export interface SalesforceIntakeProxy { + origin: string + intakeRegistry: IntakeRegistry + waitForViews: (expectedViews: ExpectedSalesforceRumView[], options?: { timeout?: number }) => Promise + waitForIdle: () => Promise + stop: () => Promise +} + +export async function startSalesforceIntakeProxy(): Promise { + const intakeRegistry = new IntakeRegistry() + const waiters = new Set() + const idleWaiter = createIdleWaiter() + const app = express() + + app.use((_req, res, next) => { + idleWaiter.trackResponse(res) + next() + }) + app.use(allowCrossOriginLoopbackRequests()) + app.post( + '/', + createIntakeProxyMiddleware({ + forward: false, + onRequest: (request) => { + intakeRegistry.push(request) + notifyWaiters(waiters, intakeRegistry) + }, + }) + ) + + const server = https.createServer(generateSelfSignedCertificate(), app) + const origin = await listen(server) + + return { + origin, + intakeRegistry, + waitForViews: (expectedViews, options) => waitForViews(waiters, intakeRegistry, expectedViews, options), + waitForIdle: () => idleWaiter.wait(), + stop: () => close(server), + } +} + +function allowCrossOriginLoopbackRequests(): express.RequestHandler { + return (req, res, next) => { + const origin = req.header('origin') + const requestedHeaders = req.header('access-control-request-headers') + + res.header('Access-Control-Allow-Origin', origin || '*') + res.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') + res.header('Access-Control-Allow-Headers', requestedHeaders || 'content-type') + res.header('Access-Control-Allow-Private-Network', 'true') + res.header('Vary', 'Origin, Access-Control-Request-Headers') + + if (req.method === 'OPTIONS') { + res.sendStatus(204) + return + } + + next() + } +} + +export async function installSalesforceRumProxy(browserContext: BrowserContext, proxyOrigin: string) { + await browserContext.addInitScript((proxy) => { + const wrappedFlag = '__ddSalesforceRumProxyWrapped__' + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null + } + + function hasRumInit( + value: unknown + ): value is Record & { init: (configuration?: unknown, ...args: unknown[]) => unknown } { + return isRecord(value) && typeof value.init === 'function' + } + + function wrapRum(rum: unknown) { + if (!hasRumInit(rum) || rum[wrappedFlag]) { + return rum + } + + const originalInit = rum.init + Object.defineProperty(rum, wrappedFlag, { + configurable: true, + value: true, + }) + rum.init = function (this: unknown, configuration?: unknown, ...args: unknown[]) { + return originalInit.call(this, { ...(isRecord(configuration) ? configuration : {}), proxy }, ...args) + } + return rum + } + + let ddRum = wrapRum(window.DD_RUM) + + try { + Object.defineProperty(window, 'DD_RUM', { + configurable: true, + get() { + return ddRum + }, + set(value) { + ddRum = wrapRum(value) + }, + }) + } catch { + wrapRum(window.DD_RUM) + } + }, proxyOrigin) +} + +export async function waitForRumProxyInitialization(page: Page, proxyOrigin: string) { + await page.waitForFunction( + (expectedProxy) => window.DD_RUM?.getInitConfiguration?.()?.proxy === expectedProxy, + proxyOrigin + ) +} + +export async function flushSalesforceRumEvents(page: Page) { + await page.evaluate(() => { + const beforeUnloadEvent = new Event('beforeunload') as Event & { __ddIsTrusted?: boolean } + beforeUnloadEvent.__ddIsTrusted = true + window.dispatchEvent(beforeUnloadEvent) + }) +} + +interface ViewWaiter { + expectedViews: ExpectedSalesforceRumView[] + timeoutId: NodeJS.Timeout + resolve: () => void + reject: (error: Error) => void +} + +function waitForViews( + waiters: Set, + intakeRegistry: IntakeRegistry, + expectedViews: ExpectedSalesforceRumView[], + { timeout = 10_000 } = {} +) { + return new Promise((resolve, reject) => { + const waiter: ViewWaiter = { + expectedViews, + timeoutId: setTimeout(() => { + waiters.delete(waiter) + reject(createTimeoutError(intakeRegistry, expectedViews)) + }, timeout), + resolve, + reject, + } + + waiters.add(waiter) + notifyWaiters(waiters, intakeRegistry) + }) +} + +function notifyWaiters(waiters: Set, intakeRegistry: IntakeRegistry) { + for (const waiter of waiters) { + if (findMissingViews(intakeRegistry, waiter.expectedViews).length === 0) { + clearTimeout(waiter.timeoutId) + waiters.delete(waiter) + waiter.resolve() + } + } +} + +function findMissingViews(intakeRegistry: IntakeRegistry, expectedViews: ExpectedSalesforceRumView[]) { + return expectedViews.filter( + ({ path, loadingType }) => + !intakeRegistry.rumViewEvents.some( + (event) => + normalizePathname(event.view.url) === normalizePathname(path) && event.view.loading_type === loadingType + ) + ) +} + +function createTimeoutError(intakeRegistry: IntakeRegistry, expectedViews: ExpectedSalesforceRumView[]) { + const expected = expectedViews.map(formatExpectedView).join(', ') + const captured = intakeRegistry.rumViewEvents.map(formatCapturedView).join(', ') || 'none' + + return new Error(`Timed out waiting for Salesforce RUM views. Expected: ${expected}. Captured: ${captured}.`) +} + +function formatExpectedView({ path, loadingType }: ExpectedSalesforceRumView) { + return `${path} (${loadingType})` +} + +function formatCapturedView(event: IntakeRegistry['rumViewEvents'][number]) { + return `${normalizePathname(event.view.url) || 'unknown'} (${event.view.loading_type || 'unknown'})` +} + +function normalizePathname(candidate: unknown) { + if (typeof candidate !== 'string' || !candidate.trim()) { + return undefined + } + + try { + const pathname = new URL(candidate, 'https://example.org').pathname + return pathname.endsWith('/') ? pathname.slice(0, -1) || '/' : pathname + } catch { + return undefined + } +} + +function listen(server: https.Server) { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(SALESFORCE_INTAKE_PROXY_PORT, () => { + server.off('error', reject) + const { port } = server.address() as AddressInfo + resolve(`https://localhost:${port}`) + }) + }).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') { + throw new Error(`Salesforce intake proxy port ${SALESFORCE_INTAKE_PROXY_PORT} is already in use.`) + } + throw error + }) +} + +function close(server: https.Server) { + return new Promise((resolve, reject) => { + const forceCloseTimeoutId = setTimeout(() => { + server.closeAllConnections() + }, SALESFORCE_INTAKE_PROXY_CLOSE_DELAY) + + server.close((error) => { + clearTimeout(forceCloseTimeoutId) + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +function createIdleWaiter() { + let pendingCount = 0 + let idlePromise = Promise.resolve() + let resolveIdlePromise: undefined | (() => void) + let waitTimeoutId: NodeJS.Timeout | undefined + + function resolveAfterDelay() { + waitTimeoutId = setTimeout(() => { + resolveIdlePromise?.() + resolveIdlePromise = undefined + }, SALESFORCE_INTAKE_PROXY_IDLE_DELAY) + } + + return { + trackResponse(res: http.ServerResponse) { + clearTimeout(waitTimeoutId) + if (!resolveIdlePromise) { + idlePromise = new Promise((resolve) => { + resolveIdlePromise = resolve + }) + } + + pendingCount += 1 + res.on('close', () => { + pendingCount -= 1 + if (pendingCount === 0) { + resolveAfterDelay() + } + }) + }, + wait() { + return idlePromise + }, + } +} + +function generateSelfSignedCertificate() { + const pki = forge.pki + const md = forge.md + const keys = pki.rsa.generateKeyPair(2048) + const cert = pki.createCertificate() + + cert.publicKey = keys.publicKey + cert.serialNumber = '01' + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1) + + const attrs = [ + { + name: 'commonName', + value: 'localhost', + }, + ] + + cert.setSubject(attrs) + cert.setIssuer(attrs) + cert.setExtensions([ + { + name: 'basicConstraints', + cA: true, + }, + { + name: 'keyUsage', + keyCertSign: true, + digitalSignature: true, + keyEncipherment: true, + }, + { + name: 'extKeyUsage', + serverAuth: true, + }, + { + name: 'subjectAltName', + altNames: [ + { + type: 2, + value: 'localhost', + }, + { + type: 7, + ip: '127.0.0.1', + }, + ], + }, + ]) + + cert.sign(keys.privateKey, md.sha256.create()) + + return { + key: pki.privateKeyToPem(keys.privateKey), + cert: pki.certificateToPem(cert), + } +} diff --git a/test/e2e/salesforce/support/salesforceRumRegistry.ts b/test/e2e/salesforce/support/salesforceRumRegistry.ts new file mode 100644 index 0000000000..41f610f766 --- /dev/null +++ b/test/e2e/salesforce/support/salesforceRumRegistry.ts @@ -0,0 +1,100 @@ +import type { Page, Request } from '@playwright/test' + +interface SalesforceRumEventView { + url?: unknown +} + +export interface SalesforceRumEvent { + type?: unknown + view?: SalesforceRumEventView +} + +export interface SalesforceRumRegistry { + rumRequests: Request[] + rumEvents: SalesforceRumEvent[] + rumViewEvents: SalesforceRumEvent[] + findViewByPath: (pathname: string) => SalesforceRumEvent | undefined + hasViewPath: (pathname: string) => boolean + stop: () => void +} + +export function createSalesforceRumRegistry(page: Page): SalesforceRumRegistry { + const rumRequests: Request[] = [] + + const onRequest = (request: Request) => { + if (isRumIntakeRequest(request)) { + rumRequests.push(request) + } + } + + page.context().on('request', onRequest) + + const registry: SalesforceRumRegistry = { + get rumRequests() { + return rumRequests + }, + get rumEvents() { + return rumRequests.flatMap((request) => getRumEvents(request)) + }, + get rumViewEvents() { + return registry.rumEvents.filter((event) => event.type === 'view') + }, + findViewByPath(pathname: string) { + const expectedPath = normalizePathname(pathname) + return registry.rumViewEvents.find((event) => normalizePathname(event.view?.url) === expectedPath) + }, + hasViewPath(pathname: string) { + return registry.findViewByPath(pathname) !== undefined + }, + stop() { + page.context().off('request', onRequest) + }, + } + + return registry +} + +function isRumIntakeRequest(request: Request) { + return request.method() === 'POST' && isRumIntakeUrl(request.url()) +} + +function isRumIntakeUrl(candidate: string) { + try { + return new URL(candidate).pathname === '/api/v2/rum' + } catch { + return false + } +} + +function getRumEvents(request: Request): SalesforceRumEvent[] { + const rawBody = request.postDataBuffer()?.toString('utf8') ?? '' + + if (!rawBody.trim()) { + return [] + } + + return rawBody + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as SalesforceRumEvent] + } catch { + return [] + } + }) +} + +function normalizePathname(candidate: unknown) { + if (typeof candidate !== 'string' || !candidate.trim()) { + return undefined + } + + try { + const pathname = new URL(candidate, 'https://example.org').pathname + return pathname.endsWith('/') ? pathname.slice(0, -1) || '/' : pathname + } catch { + return undefined + } +} diff --git a/test/e2e/salesforce/support/salesforceTargets.ts b/test/e2e/salesforce/support/salesforceTargets.ts new file mode 100644 index 0000000000..854aeafc6d --- /dev/null +++ b/test/e2e/salesforce/support/salesforceTargets.ts @@ -0,0 +1,58 @@ +import { execFileSync } from 'node:child_process' + +let cachedTargets: SalesforceTargets | undefined +const ANSI_ESCAPE_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +export interface SalesforceTargets { + loginUrl: string + experienceUrl: string + experienceProductExplorerUrl: string + lightningHomeUrl: string + lightningProductExplorerUrl: string +} + +// Uses `sf org open --url-only --json` to obtain an authenticated org URL. +// Reference: https://github.com/salesforcecli/plugin-org#sf-org-open +export function getSalesforceTargets() { + if (cachedTargets) { + return cachedTargets + } + + const environment = { ...process.env } + delete environment.NO_COLOR + const stdout = execFileSync( + 'sf', + ['org', 'open', '-o', 'ebikes', '--url-only', '--path', '/lightning/page/home', '--json'], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...environment, FORCE_COLOR: '0', CLICOLOR: '0' }, + } + ) + + const result = JSON.parse(stripAnsi(stdout)) as { result?: { url?: string } } + const loginUrl = result.result?.url + + if (!loginUrl) { + throw new Error('sf org open did not return an authenticated URL.') + } + + // Derive Lightning and Experience Cloud origins from the authenticated org URL. + const loginOrigin = new URL(loginUrl).origin + const lightningOrigin = loginOrigin.replace('.my.salesforce.com', '.lightning.force.com') + const experienceOrigin = loginOrigin.replace('.my.salesforce.com', '.my.site.com') + + cachedTargets = { + loginUrl, + experienceUrl: `${experienceOrigin}/ebikes/s`, + experienceProductExplorerUrl: `${experienceOrigin}/ebikes/s/product-explorer`, + lightningHomeUrl: `${lightningOrigin}/lightning/page/home`, + lightningProductExplorerUrl: `${lightningOrigin}/lightning/n/Product_Explorer`, + } + + return cachedTargets +} + +function stripAnsi(candidate: string) { + return candidate.replace(ANSI_ESCAPE_SEQUENCE, '') +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 040b37b41f..6add355941 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,7 +25,6 @@ "@datadog/browser-rum": ["./packages/rum/src/entries/main"], "@datadog/browser-rum/internal": ["./packages/rum/src/entries/internal"], "@datadog/browser-rum/internal-synthetics": ["./packages/rum/src/entries/internalSynthetics"], - "@datadog/browser-rum-slim": ["./packages/rum-slim/src/entries/main"], "@datadog/browser-rum-react": ["./packages/rum-react/src/entries/main"],