diff --git a/packages/rum-core/src/browser/htmlDomUtils.ts b/packages/rum-core/src/browser/htmlDomUtils.ts index d7404c19cd..6995e7328c 100644 --- a/packages/rum-core/src/browser/htmlDomUtils.ts +++ b/packages/rum-core/src/browser/htmlDomUtils.ts @@ -43,3 +43,19 @@ export function forEachChildNodes(node: Node, callback: (child: Node) => void) { export function getParentNode(node: Node): Node | null { return isNodeShadowRoot(node) ? node.host : node.parentNode } + +/** + * Return the parent element, crossing shadow DOM boundaries. + * If the element is a direct child of a shadow root, returns the shadow host. + */ +export function getParentElement(element: Element): Element | null { + if (element.parentElement) { + return element.parentElement + } + // If parentElement is null, check if we're in a shadow root + const parentNode = element.parentNode + if (parentNode && isNodeShadowRoot(parentNode)) { + return parentNode.host + } + return null +} diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts index 8a3c03b403..cc28ba081a 100644 --- a/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts +++ b/packages/rum-core/src/domain/action/getActionNameFromElement.spec.ts @@ -930,4 +930,123 @@ describe('getActionNameFromElement', () => { }) }) }) + + describe('shadow DOM support', () => { + it('extracts text content from element inside shadow DOM', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const button = document.createElement('button') + button.textContent = 'Shadow Button' + shadowRoot.appendChild(button) + + const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration) + expect(name).toBe('Shadow Button') + expect(nameSource).toBe('text_content') + }) + + it('extracts aria-label from element inside shadow DOM', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const button = document.createElement('button') + button.setAttribute('aria-label', 'Accessible Label') + shadowRoot.appendChild(button) + + const { name, nameSource } = getActionNameFromElement(button, defaultConfiguration) + expect(name).toBe('Accessible Label') + expect(nameSource).toBe('standard_attribute') + }) + + it('traverses parent elements across shadow boundary to find action name', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const wrapper = document.createElement('div') + wrapper.setAttribute('title', 'Parent Title') + const target = document.createElement('span') + wrapper.appendChild(target) + shadowRoot.appendChild(wrapper) + + const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration) + expect(name).toBe('Parent Title') + expect(nameSource).toBe('standard_attribute') + }) + + it('finds data-dd-action-name on shadow host when element is inside shadow DOM', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const target = document.createElement('button') + shadowRoot.appendChild(target) + + const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration) + expect(name).toBe('Custom Action') + expect(nameSource).toBe('custom_attribute') + }) + + it('finds data-dd-action-name on ancestor outside shadow DOM', () => { + const ancestor = appendElement('
') + const host = ancestor.querySelector('#host')! + const shadowRoot = host.attachShadow({ mode: 'open' }) + const target = document.createElement('button') + shadowRoot.appendChild(target) + + const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration) + expect(name).toBe('Ancestor Action') + expect(nameSource).toBe('custom_attribute') + }) + + it('prefers data-dd-action-name closer to the element inside shadow DOM', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const wrapper = document.createElement('div') + wrapper.setAttribute('data-dd-action-name', 'Inner Action') + const target = document.createElement('button') + wrapper.appendChild(target) + shadowRoot.appendChild(wrapper) + + const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration) + expect(name).toBe('Inner Action') + expect(nameSource).toBe('custom_attribute') + }) + + it('handles nested shadow DOMs', () => { + const outerHost = appendElement('
') + const outerShadowRoot = outerHost.attachShadow({ mode: 'open' }) + + const innerHost = document.createElement('div') + outerShadowRoot.appendChild(innerHost) + const innerShadowRoot = innerHost.attachShadow({ mode: 'open' }) + + const target = document.createElement('button') + innerShadowRoot.appendChild(target) + + const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration) + expect(name).toBe('Outer Action') + expect(nameSource).toBe('custom_attribute') + }) + + it('gets parent textual content from shadow host when element has no name', () => { + const wrapper = appendElement('
') + const host = wrapper.querySelector('#host-container')! + host.setAttribute('title', 'Host Title') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const target = document.createElement('span') + shadowRoot.appendChild(target) + + const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration) + expect(name).toBe('Host Title') + expect(nameSource).toBe('standard_attribute') + }) + + it('respects FORM boundary inside shadow DOM', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const form = document.createElement('form') + const target = document.createElement('span') + form.appendChild(target) + shadowRoot.appendChild(form) + + const { name, nameSource } = getActionNameFromElement(target, defaultConfiguration) + expect(name).toBe('') + expect(nameSource).toBe('blank') + }) + }) }) diff --git a/packages/rum-core/src/domain/action/getActionNameFromElement.ts b/packages/rum-core/src/domain/action/getActionNameFromElement.ts index 0c937cca65..3431cf2e21 100644 --- a/packages/rum-core/src/domain/action/getActionNameFromElement.ts +++ b/packages/rum-core/src/domain/action/getActionNameFromElement.ts @@ -3,7 +3,7 @@ import { getPrivacySelector, NodePrivacyLevel } from '../privacyConstants' import { getNodePrivacyLevel, maskDisallowedTextContent, shouldMaskNode, shouldMaskAttribute } from '../privacy' import type { NodePrivacyLevelCache } from '../privacy' import type { RumConfiguration } from '../configuration' -import { isElementNode } from '../../browser/htmlDomUtils' +import { isElementNode, getParentElement } from '../../browser/htmlDomUtils' import { ActionNameSource, DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE, @@ -49,7 +49,8 @@ export function getActionNameFromElement( function getActionNameFromElementProgrammatically(targetElement: Element, programmaticAttribute: string) { // We don't use getActionNameFromElementForStrategies here, because we want to consider all parents, // without limit. It is up to the user to declare a relevant naming strategy. - const elementWithAttribute = targetElement.closest(`[${programmaticAttribute}]`) + // Use shadow-aware closest to cross shadow DOM boundaries + const elementWithAttribute = closestShadowAware(targetElement, `[${programmaticAttribute}]`) if (!elementWithAttribute) { return @@ -58,6 +59,17 @@ function getActionNameFromElementProgrammatically(targetElement: Element, progra return truncate(normalizeWhitespace(name.trim())) } +function closestShadowAware(element: Element, selector: string): Element | null { + let current: Element | null = element + while (current) { + if (current.matches(selector)) { + return current + } + current = getParentElement(current) + } + return null +} + type NameStrategy = ( element: Element | HTMLElement | HTMLInputElement | HTMLSelectElement, rumConfiguration: RumConfiguration, @@ -128,6 +140,7 @@ const fallbackStrategies: NameStrategy[] = [ /** * Iterates over the target element and its parent, using the strategies list to get an action name. * Each strategies are applied on each element, stopping as soon as a non-empty value is returned. + * Uses shadow-aware parent traversal to cross shadow DOM boundaries. */ const MAX_PARENTS_TO_CONSIDER = 10 function getActionNameFromElementForStrategies( @@ -160,7 +173,7 @@ function getActionNameFromElementForStrategies( if (element.nodeName === 'FORM') { break } - element = element.parentElement + element = getParentElement(element) recursionCounter += 1 } } diff --git a/packages/rum-core/src/domain/action/trackClickActions.ts b/packages/rum-core/src/domain/action/trackClickActions.ts index bf4a11af29..57b5845cf4 100644 --- a/packages/rum-core/src/domain/action/trackClickActions.ts +++ b/packages/rum-core/src/domain/action/trackClickActions.ts @@ -10,6 +10,7 @@ import { createValueHistory, PageExitReason, } from '@datadog/browser-core' +import { isNodeShadowHost } from '../../browser/htmlDomUtils' import type { FrustrationType } from '../../rawRumEvent.types' import { ActionType } from '../../rawRumEvent.types' import type { LifeCycle } from '../lifeCycle' @@ -143,10 +144,12 @@ function processPointerDown( pointerDownEvent: MouseEventOnElement, windowOpenObservable: Observable ) { + const target = configuration.trackActionsInShadowDom ? getEventTarget(pointerDownEvent) : pointerDownEvent.target + let nodePrivacyLevel: NodePrivacyLevel if (configuration.enablePrivacyForActionName) { - nodePrivacyLevel = getNodePrivacyLevel(pointerDownEvent.target, configuration.defaultPrivacyLevel) + nodePrivacyLevel = getNodePrivacyLevel(target, configuration.defaultPrivacyLevel) } else { nodePrivacyLevel = NodePrivacyLevel.ALLOW } @@ -244,13 +247,18 @@ function computeClickActionBase( nodePrivacyLevel: NodePrivacyLevel, configuration: RumConfiguration ): ClickActionBase { - const rect = event.target.getBoundingClientRect() - const selector = getSelectorFromElement(event.target, configuration.actionNameAttribute) + // Get the actual target element, handling shadow DOM if enabled + // For composed events from shadow DOM, event.target might be the shadow host, + // but we want the actual element that was clicked inside the shadow root + const target = configuration.trackActionsInShadowDom ? getEventTarget(event) : event.target + + const rect = target.getBoundingClientRect() + const selector = getSelectorFromElement(target, configuration.actionNameAttribute) if (selector) { updateInteractionSelector(event.timeStamp, selector) } - const { name, nameSource } = getActionNameFromElement(event.target, configuration, nodePrivacyLevel) + const { name, nameSource } = getActionNameFromElement(target, configuration, nodePrivacyLevel) return { type: ActionType.CLICK, @@ -268,6 +276,15 @@ function computeClickActionBase( nameSource, } } +function getEventTarget(event: MouseEventOnElement): Element { + if (event.composed && isNodeShadowHost(event.target) && typeof event.composedPath === 'function') { + const composedPath = event.composedPath() + if (composedPath.length > 0 && composedPath[0] instanceof Element) { + return composedPath[0] + } + } + return event.target +} const enum ClickStatus { // Initial state, the click is still ongoing. diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 06e5fdb973..747ea325a7 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -612,6 +612,7 @@ describe('serializeRumConfiguration', () => { trackFeatureFlagsForEvents: ['vital'], profilingSampleRate: 42, propagateTraceBaggage: true, + trackActionsInShadowDom: true, } type MapRumInitConfigurationKey = Key extends keyof InitConfiguration @@ -626,7 +627,8 @@ describe('serializeRumConfiguration', () => { : Key extends 'trackLongTasks' ? 'track_long_task' // We forgot the s, keeping this for backward compatibility : // The following options are not reported as telemetry. Please avoid adding more of them. - Key extends 'applicationId' | 'subdomain' + // TODO: Remove trackActionsInShadowDom from this list once rum-events-format is updated + Key extends 'applicationId' | 'subdomain' | 'trackActionsInShadowDom' ? never : CamelToSnakeCase // By specifying the type here, we can ensure that serializeConfiguration is returning an diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index f41e76b0ca..a078214bd5 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -203,6 +203,15 @@ export interface RumInitConfiguration extends InitConfiguration { */ actionNameAttribute?: string | undefined + /** + * Enables tracking of user interactions within Shadow DOM elements. + * When enabled, click actions inside Shadow DOM will have accurate names and selectors. + * + * @category Data Collection + * @defaultValue false + */ + trackActionsInShadowDom?: boolean | undefined + // view options /** * Allows you to control RUM views creation. See [Override default RUM view names](https://docs.datadoghq.com/real_user_monitoring/browser/advanced_configuration/?tab=npm#override-default-rum-view-names) for further information. @@ -286,6 +295,7 @@ export interface GraphQlUrlOption { export interface RumConfiguration extends Configuration { // Built from init configuration actionNameAttribute: string | undefined + trackActionsInShadowDom: boolean traceSampleRate: number rulePsr: number | undefined allowedTracingUrls: TracingOption[] @@ -358,6 +368,7 @@ export function validateAndBuildRumConfiguration( return { applicationId: initConfiguration.applicationId, actionNameAttribute: initConfiguration.actionNameAttribute, + trackActionsInShadowDom: !!initConfiguration.trackActionsInShadowDom, sessionReplaySampleRate, startSessionReplayRecordingManually: initConfiguration.startSessionReplayRecordingManually !== undefined @@ -533,6 +544,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) { remote_configuration_id: configuration.remoteConfigurationId, profiling_sample_rate: configuration.profilingSampleRate, use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy, + // TODO: Add track_actions_in_shadow_dom once rum-events-format is updated ...baseSerializedConfiguration, } satisfies RawTelemetryConfiguration } diff --git a/packages/rum-core/src/domain/getSelectorFromElement.spec.ts b/packages/rum-core/src/domain/getSelectorFromElement.spec.ts index 5a1b40bee1..727698e647 100644 --- a/packages/rum-core/src/domain/getSelectorFromElement.spec.ts +++ b/packages/rum-core/src/domain/getSelectorFromElement.spec.ts @@ -1,5 +1,5 @@ import { appendElement } from '../../test' -import { getSelectorFromElement, isSelectorUniqueAmongSiblings } from './getSelectorFromElement' +import { getSelectorFromElement, isSelectorUniqueAmongSiblings, SHADOW_DOM_MARKER } from './getSelectorFromElement' describe('getSelectorFromElement', () => { afterEach(() => { @@ -220,3 +220,114 @@ describe('isSelectorUniqueAmongSiblings', () => { expect(isSelectorUniqueAmongSiblings(element, 'DIV', 'HR')).toBeTrue() }) }) + +describe('getSelectorFromElement with shadow DOM', () => { + it('should generate selector with shadow marker for element inside shadow DOM', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const button = document.createElement('button') + button.classList.add('shadow-button') + shadowRoot.appendChild(button) + + const selector = getSelectorFromElement(button, undefined) + expect(selector).toBeDefined() + expect(selector).toContain(SHADOW_DOM_MARKER) + expect(selector).toContain('BUTTON') + }) + + it('should use stable attribute for element inside shadow DOM with shadow marker', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const button = document.createElement('button') + button.setAttribute('data-testid', 'shadow-test') + shadowRoot.appendChild(button) + + const selector = getSelectorFromElement(button, undefined) + expect(selector).toContain(SHADOW_DOM_MARKER) + expect(selector).toContain('data-testid="shadow-test"') + }) + + it('should insert shadow marker when traversing shadow boundary', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + const button = document.createElement('button') + shadowRoot.appendChild(button) + + const selector = getSelectorFromElement(button, undefined) + + expect(selector).toContain(SHADOW_DOM_MARKER) + expect(selector).toContain('BUTTON') + }) + + it('should handle nested shadow DOMs with multiple markers', () => { + const outerHost = appendElement('
') + const outerShadowRoot = outerHost.attachShadow({ mode: 'open' }) + + const innerHost = document.createElement('div') + innerHost.setAttribute('data-testid', 'inner-host') + outerShadowRoot.appendChild(innerHost) + const innerShadowRoot = innerHost.attachShadow({ mode: 'open' }) + + const button = document.createElement('button') + button.setAttribute('data-testid', 'deep-button') + innerShadowRoot.appendChild(button) + + const selector = getSelectorFromElement(button, undefined) + expect(selector).toBeDefined() + + const markerCount = ( + selector!.match(new RegExp(SHADOW_DOM_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || [] + ).length + expect(markerCount).toBe(2) + expect(selector).toContain('data-testid="deep-button"') + }) + + it('should use position selector inside shadow DOM with shadow marker', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + + const div1 = document.createElement('div') + const div2 = document.createElement('div') + const target = document.createElement('span') + div2.appendChild(target) + shadowRoot.appendChild(div1) + shadowRoot.appendChild(div2) + + const selector = getSelectorFromElement(target, undefined) + expect(selector).toBeDefined() + expect(selector).toContain(SHADOW_DOM_MARKER) + expect(selector).toContain('SPAN') + }) + + it('should generate unique selector when siblings exist inside shadow DOM', () => { + const host = appendElement('
') + const shadowRoot = host.attachShadow({ mode: 'open' }) + + const button1 = document.createElement('button') + button1.classList.add('first') + const button2 = document.createElement('button') + button2.classList.add('second') + + shadowRoot.appendChild(button1) + shadowRoot.appendChild(button2) + + const selector1 = getSelectorFromElement(button1, undefined) + const selector2 = getSelectorFromElement(button2, undefined) + + expect(selector1).toBeDefined() + expect(selector2).toBeDefined() + expect(selector1).not.toBe(selector2) + expect(selector1).toContain(SHADOW_DOM_MARKER) + expect(selector2).toContain(SHADOW_DOM_MARKER) + }) + + it('should NOT add shadow marker for elements in light DOM', () => { + const element = appendElement('
') + const button = element.querySelector('button')! + + const selector = getSelectorFromElement(button, undefined) + expect(selector).toBeDefined() + expect(selector).not.toContain(SHADOW_DOM_MARKER) + expect(selector).toBe('BODY>DIV>BUTTON.light-btn') + }) +}) diff --git a/packages/rum-core/src/domain/getSelectorFromElement.ts b/packages/rum-core/src/domain/getSelectorFromElement.ts index 287f27aaf0..aa5cf046d0 100644 --- a/packages/rum-core/src/domain/getSelectorFromElement.ts +++ b/packages/rum-core/src/domain/getSelectorFromElement.ts @@ -1,5 +1,8 @@ +import { getParentElement, isNodeShadowRoot } from '../browser/htmlDomUtils' import { DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE } from './action/actionNameConstants' +export const SHADOW_DOM_MARKER = ' /shadow/ ' + /** * Stable attributes are attributes that are commonly used to identify parts of a UI (ex: * component). Those attribute values should not be generated randomly (hardcoded most of the time) @@ -46,8 +49,11 @@ export function getSelectorFromElement( // parents, and we cannot determine if it's unique in the document. return } - let targetElementSelector: string | undefined + + let cssSelector: string | undefined + let outputSelector: string | undefined let currentElement: Element | null = targetElement + let nextIsShadowBoundary = false while (currentElement && currentElement.nodeName !== 'HTML') { const globallyUniqueSelector = findSelector( @@ -55,10 +61,10 @@ export function getSelectorFromElement( GLOBALLY_UNIQUE_SELECTOR_GETTERS, isSelectorUniqueGlobally, actionNameAttribute, - targetElementSelector + cssSelector ) if (globallyUniqueSelector) { - return globallyUniqueSelector + return combineSelector(globallyUniqueSelector, outputSelector, nextIsShadowBoundary) } const uniqueSelectorAmongChildren = findSelector( @@ -66,15 +72,29 @@ export function getSelectorFromElement( UNIQUE_AMONG_CHILDREN_SELECTOR_GETTERS, isSelectorUniqueAmongSiblings, actionNameAttribute, - targetElementSelector + cssSelector ) - targetElementSelector = - uniqueSelectorAmongChildren || combineSelector(getPositionSelector(currentElement), targetElementSelector) - currentElement = currentElement.parentElement + const currentSelector = uniqueSelectorAmongChildren || getPositionSelector(currentElement) + + cssSelector = combineSelector(currentSelector, cssSelector) + outputSelector = combineSelector(currentSelector, outputSelector, nextIsShadowBoundary) + + nextIsShadowBoundary = isCrossingShadowBoundary(currentElement) + + currentElement = getParentElement(currentElement) } - return targetElementSelector + return outputSelector +} + +/** + * Check if traversing to the parent element will cross a shadow DOM boundary. + * This happens when the element is a direct child of a shadow root. + */ +function isCrossingShadowBoundary(element: Element): boolean { + const parentNode = element.parentNode + return parentNode !== null && isNodeShadowRoot(parentNode) } function isGeneratedValue(value: string) { @@ -136,7 +156,12 @@ function getStableAttributeSelector(element: Element, actionNameAttribute: strin } function getPositionSelector(element: Element): string { - let sibling = element.parentElement!.firstElementChild + const parent = getElementParentNode(element) + if (!parent) { + return CSS.escape(element.tagName) + } + + let sibling = parent.firstElementChild let elementIndex = 1 while (sibling && sibling !== element) { @@ -149,6 +174,22 @@ function getPositionSelector(element: Element): string { return `${CSS.escape(element.tagName)}:nth-of-type(${elementIndex})` } +/** + * Get the parent node that contains the element's siblings. + * For elements in shadow DOM, this is the shadow root. + * For regular elements, this is the parent element. + */ +function getElementParentNode(element: Element): Element | ShadowRoot | null { + if (element.parentElement) { + return element.parentElement + } + const parentNode = element.parentNode + if (parentNode && isNodeShadowRoot(parentNode)) { + return parentNode + } + return null +} + function findSelector( element: Element, selectorGetters: SelectorGetter[], @@ -162,7 +203,7 @@ function findSelector( continue } if (predicate(element, elementSelector, childSelector)) { - return combineSelector(elementSelector, childSelector) + return elementSelector } } } @@ -249,7 +290,12 @@ export function isSelectorUniqueAmongSiblings( isSiblingMatching = (sibling) => sibling.querySelector(scopedSelector) !== null } - const parent = currentElement.parentElement! + const parent = getElementParentNode(currentElement) + if (!parent) { + // If there's no parent (edge case), consider it unique + return true + } + let sibling = parent.firstElementChild while (sibling) { if (sibling !== currentElement && isSiblingMatching(sibling)) { @@ -261,6 +307,10 @@ export function isSelectorUniqueAmongSiblings( return true } -function combineSelector(parent: string, child: string | undefined): string { - return child ? `${parent}>${child}` : parent +function combineSelector(parent: string, child: string | undefined, useShadowSeparator = false): string { + if (!child) { + return parent + } + const separator = useShadowSeparator ? SHADOW_DOM_MARKER : '>' + return `${parent}${separator}${child}` } diff --git a/test/e2e/scenario/rum/actions.scenario.ts b/test/e2e/scenario/rum/actions.scenario.ts index 593c9d372b..12bf7be911 100644 --- a/test/e2e/scenario/rum/actions.scenario.ts +++ b/test/e2e/scenario/rum/actions.scenario.ts @@ -455,3 +455,116 @@ test.describe('action collection', () => { }) }) }) + +test.describe('action collection with shadow DOM', () => { + createTest('without trackShadowDom, click inside shadow DOM uses shadow host as target') + .withRum({ trackUserInteractions: true }) + .withBody(html` + + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + const button = page.locator('my-button').first().locator('button') + await button.click() + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action?.target?.name).toBe('') + expect(actionEvents[0]._dd.action?.target?.selector).toBe('#shadow-host') + }) + + createTest('with trackShadowDom, get action name from element inside shadow DOM') + .withRum({ trackUserInteractions: true, trackActionsInShadowDom: true }) + .withBody(html` + + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + const button = page.locator('my-button').first().locator('button') + await button.click() + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action?.target?.name).toBe('Shadow Button') + expect(actionEvents[0]._dd.action?.target?.selector).toContain('BUTTON') + }) + + createTest('with trackShadowDom, traverse shadow boundary for data-dd-action-name') + .withRum({ trackUserInteractions: true, trackActionsInShadowDom: true }) + .withBody(html` + + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + const button = page.locator('my-button').first().locator('button') + await button.click() + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0].action?.target?.name).toBe('Custom Shadow Action') + }) + + createTest('with trackShadowDom, selector includes stable attributes from inside shadow DOM') + .withRum({ trackUserInteractions: true, trackActionsInShadowDom: true }) + .withBody(html` + + + `) + .run(async ({ intakeRegistry, flushEvents, page }) => { + const button = page.locator('my-button').first().locator('button') + await button.click() + await flushEvents() + + const actionEvents = intakeRegistry.rumActionEvents + expect(actionEvents).toHaveLength(1) + expect(actionEvents[0]._dd.action?.target?.selector).toContain('data-testid') + }) +})