diff --git a/packages/browser-utils/src/domEventNormalizer.ts b/packages/browser-utils/src/domEventNormalizer.ts new file mode 100644 index 000000000000..a64d8eb496cc --- /dev/null +++ b/packages/browser-utils/src/domEventNormalizer.ts @@ -0,0 +1,41 @@ +import { addNormalizeUnpacker, isInstanceOf } from '@sentry/core'; +import { serializeEventTarget } from './object'; + +let unregister: (() => void) | undefined; + +/** + * Registers a `normalize()` unpacker that extracts the meaningful fields of a DOM + * `Event` (`type`, `target`, `currentTarget`, optional `detail`) into a plain object + * before `normalize()` walks it. Element targets are serialized via `htmlTreeAsString` + * so the user sees a CSS-selector-style path rather than `[object HTMLButtonElement]`. + * + * Idempotent — calling more than once is a no-op (the existing registration is reused). + * + * Returns the unregister function from `addNormalizeUnpacker` (useful for tests). + */ +export function registerDomEventNormalizer(): () => void { + if (unregister) { + return unregister; + } + unregister = addNormalizeUnpacker(value => { + if (typeof Event === 'undefined' || !(value instanceof Event)) { + return undefined; + } + const result: Record = { + type: value.type, + target: serializeEventTarget(value.target), + currentTarget: serializeEventTarget(value.currentTarget), + ...Object.fromEntries(Object.entries(value)), + }; + if (typeof CustomEvent !== 'undefined' && isInstanceOf(value, CustomEvent)) { + result.detail = (value as CustomEvent).detail; + } + return result; + }); + + const baseUnregister = unregister; + return () => { + baseUnregister(); + unregister = undefined; + }; +} diff --git a/packages/browser-utils/src/htmlTreeAsString.ts b/packages/browser-utils/src/htmlTreeAsString.ts new file mode 100644 index 000000000000..0eca02e60985 --- /dev/null +++ b/packages/browser-utils/src/htmlTreeAsString.ts @@ -0,0 +1,124 @@ +import { isString } from '@sentry/core'; + +const DEFAULT_MAX_STRING_LENGTH = 80; + +type SimpleNode = { + parentNode: SimpleNode; +} | null; + +/** + * Given a child DOM element, returns a query-selector statement describing that + * and its ancestors + * e.g. [HTMLElement] => body > div > input#foo.btn[name=baz] + * @returns generated DOM path + */ +export function htmlTreeAsString( + elem: unknown, + options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {}, +): string { + if (!elem) { + return ''; + } + + // try/catch both: + // - accessing event.target (see getsentry/raven-js#838, #768) + // - `htmlTreeAsString` because it's complex, and just accessing the DOM incorrectly + // - can throw an exception in some circumstances. + try { + let currentElem = elem as SimpleNode; + const MAX_TRAVERSE_HEIGHT = 5; + const out = []; + let height = 0; + let len = 0; + const separator = ' > '; + const sepLength = separator.length; + let nextStr; + const keyAttrs = Array.isArray(options) ? options : options.keyAttrs; + const maxStringLength = (!Array.isArray(options) && options.maxStringLength) || DEFAULT_MAX_STRING_LENGTH; + + while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) { + nextStr = _htmlElementAsString(currentElem, keyAttrs); + // bail out if + // - nextStr is the 'html' element + // - the length of the string that would be created exceeds maxStringLength + // (ignore this limit if we are on the first iteration) + if (nextStr === 'html' || (height > 1 && len + out.length * sepLength + nextStr.length >= maxStringLength)) { + break; + } + + out.push(nextStr); + + len += nextStr.length; + currentElem = currentElem.parentNode; + } + + return out.reverse().join(separator); + } catch { + return ''; + } +} + +/** + * Returns a simple, query-selector representation of a DOM element + * e.g. [HTMLElement] => input#foo.btn[name=baz] + * @returns generated DOM path + */ +function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { + const elem = el as { + tagName?: string; + id?: string; + className?: string; + getAttribute(key: string): string; + }; + + const out = []; + + if (!elem?.tagName) { + return ''; + } + + if (typeof HTMLElement !== 'undefined') { + // If using the component name annotation plugin, this value may be available on the DOM node + if (elem instanceof HTMLElement && elem.dataset) { + if (elem.dataset['sentryComponent']) { + return elem.dataset['sentryComponent']; + } + if (elem.dataset['sentryElement']) { + return elem.dataset['sentryElement']; + } + } + } + + out.push(elem.tagName.toLowerCase()); + + // Pairs of attribute keys defined in `serializeAttribute` and their values on element. + const keyAttrPairs = keyAttrs?.length + ? keyAttrs.filter(keyAttr => elem.getAttribute(keyAttr)).map(keyAttr => [keyAttr, elem.getAttribute(keyAttr)]) + : null; + + if (keyAttrPairs?.length) { + keyAttrPairs.forEach(keyAttrPair => { + out.push(`[${keyAttrPair[0]}="${keyAttrPair[1]}"]`); + }); + } else { + if (elem.id) { + out.push(`#${elem.id}`); + } + + const className = elem.className; + if (className && isString(className)) { + const classes = className.split(/\s+/); + for (const c of classes) { + out.push(`.${c}`); + } + } + } + for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) { + const attr = elem.getAttribute(k); + if (attr) { + out.push(`[${k}="${attr}"]`); + } + } + + return out.join(''); +} diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 888524ed7c21..467c1abe4acf 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -34,4 +34,10 @@ export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrRespo export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; +export { convertToPlainObject, extractExceptionKeysForMessage } from './object'; + +export { htmlTreeAsString } from './htmlTreeAsString'; + +export { registerDomEventNormalizer } from './domEventNormalizer'; + export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 76e853eef5d3..7fa40ee101cb 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -5,7 +5,6 @@ import { debug, getActiveSpan, getComponentName, - htmlTreeAsString, isPrimitive, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -13,6 +12,7 @@ import { spanToJSON, stringMatchesSomePattern, } from '@sentry/core'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; import { trackClsAsStandaloneSpan } from './cls'; import { diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index d836ff315c06..4c09dde19c74 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -3,7 +3,6 @@ import { browserPerformanceTimeOrigin, debug, getCurrentScope, - htmlTreeAsString, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, @@ -12,6 +11,7 @@ import { timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { addClsInstrumentationHandler } from './instrument'; import type { WebVitalReportEvent } from './utils'; import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 3eb0b2920a75..158d4b4212c2 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -4,7 +4,6 @@ import { getActiveSpan, getCurrentScope, getRootSpan, - htmlTreeAsString, isBrowser, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, @@ -13,6 +12,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; import type { InstrumentationHandlerCallback } from './instrument'; import { diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index c11f6bd63cbc..bcd065e94cf0 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -3,7 +3,6 @@ import { browserPerformanceTimeOrigin, debug, getCurrentScope, - htmlTreeAsString, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, @@ -11,6 +10,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { addLcpInstrumentationHandler } from './instrument'; import type { WebVitalReportEvent } from './utils'; import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index 6f6d8de3901e..ebe1dc7198ad 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -5,7 +5,6 @@ import { getActiveSpan, getCurrentScope, getRootSpan, - htmlTreeAsString, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -13,6 +12,7 @@ import { startInactiveSpan, timestampInSeconds, } from '@sentry/core'; +import { htmlTreeAsString } from '../htmlTreeAsString'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../types'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; diff --git a/packages/browser-utils/src/object.ts b/packages/browser-utils/src/object.ts new file mode 100644 index 000000000000..b9f2bcf53a36 --- /dev/null +++ b/packages/browser-utils/src/object.ts @@ -0,0 +1,79 @@ +import { isElement, isEvent, isInstanceOf, convertToPlainObject as originalConvertToPlainObject } from '@sentry/core'; +import { htmlTreeAsString } from './htmlTreeAsString'; + +/** + * Transforms any `Error` or `Event` into a plain object with all of their enumerable properties, and some of their + * non-enumerable properties attached. + * + * @param value Initial source that we have to transform in order for it to be usable by the serializer + * @returns An Event or Error turned into an object - or the value argument itself, when value is neither an Event nor + * an Error. + */ +export function convertToPlainObject(value: V): + | { + [ownProps: string]: unknown; + type: string; + target: string; + currentTarget: string; + detail?: unknown; + } + | { + [ownProps: string]: unknown; + message: string; + name: string; + stack?: string; + } + | V { + if (isEvent(value)) { + const newObj: { + [ownProps: string]: unknown; + type: string; + target: string; + currentTarget: string; + detail?: unknown; + } = { + type: value.type, + target: serializeEventTarget(value.target), + currentTarget: serializeEventTarget(value.currentTarget), + ...getOwnProperties(value), + }; + + if (typeof CustomEvent !== 'undefined' && isInstanceOf(value, CustomEvent)) { + newObj.detail = value.detail; + } + + return newObj; + } + + return originalConvertToPlainObject(value); +} + +export function serializeEventTarget(target: unknown): string { + try { + return isElement(target) ? htmlTreeAsString(target) : Object.prototype.toString.call(target); + } catch { + return ''; + } +} + +function getOwnProperties(obj: unknown): { [key: string]: unknown } { + if (typeof obj === 'object' && obj !== null) { + return Object.fromEntries(Object.entries(obj)); + } + return {}; +} + +/** + * Given any captured exception, extract its keys and create a sorted + * and truncated list that will be used inside the event message. + * eg. `Non-error exception captured with keys: foo, bar, baz` + * + * The browser variant produces richer output for `Event` exceptions + * (e.g. extracting `type`, `target`, `currentTarget`). + */ +export function extractExceptionKeysForMessage(exception: Record): string { + const keys = Object.keys(convertToPlainObject(exception)); + keys.sort(); + + return !keys[0] ? '[object has no keys]' : keys.join(', '); +} diff --git a/packages/browser-utils/test/domEventNormalizer.test.ts b/packages/browser-utils/test/domEventNormalizer.test.ts new file mode 100644 index 000000000000..0ab80dc81f67 --- /dev/null +++ b/packages/browser-utils/test/domEventNormalizer.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment jsdom + */ + +import { normalize } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { registerDomEventNormalizer } from '../src/domEventNormalizer'; + +describe('registerDomEventNormalizer()', () => { + let unregister: () => void; + + beforeEach(() => { + unregister = registerDomEventNormalizer(); + }); + + afterEach(() => { + unregister(); + }); + + test('extracts type/target/currentTarget from a DOM Event with element targets', () => { + document.body.innerHTML = '
'; + const button = document.querySelector('button')!; + const root = document.querySelector('#root')!; + + const evt = new Event('click'); + Object.defineProperty(evt, 'target', { value: button }); + Object.defineProperty(evt, 'currentTarget', { value: root }); + + const result = normalize(evt); + expect(result.type).toBe('click'); + // htmlTreeAsString output — selector-style path, not `[object HTMLButtonElement]`. + expect(result.target).toContain('button.action'); + expect(result.currentTarget).toContain('div#root'); + }); + + test('falls back to `[object …]` for non-Element targets', () => { + const evt = new Event('foo'); + Object.defineProperty(evt, 'target', { value: { not: 'an element' } }); + Object.defineProperty(evt, 'currentTarget', { value: null }); + + const result = normalize(evt); + expect(result.target).toBe('[object Object]'); + expect(result.currentTarget).toBe('[object Null]'); + }); + + test('extracts detail from CustomEvent', () => { + const evt = new CustomEvent('bar', { detail: { payload: 42 } }); + + const result = normalize(evt); + expect(result.type).toBe('bar'); + expect(result.detail).toEqual({ payload: 42 }); + }); + + test('preserves enumerable own properties on the Event', () => { + const evt = new Event('foo') as Event & { extraInfo: string }; + evt.extraInfo = 'meta'; + + const result = normalize(evt); + expect(result.type).toBe('foo'); + expect(result.extraInfo).toBe('meta'); + }); + + test('does not interfere with non-Event values', () => { + expect(normalize({ foo: 'bar' })).toEqual({ foo: 'bar' }); + expect(normalize('string')).toBe('string'); + expect(normalize(42)).toBe(42); + expect(normalize(null)).toBe(null); + }); + + test('is idempotent — registering twice still registers a single handler', () => { + // beforeEach already registered once; register again. + const secondUnregister = registerDomEventNormalizer(); + + const result = normalize(new Event('foo')); + expect(result.type).toBe('foo'); + + // Now unregister once via the returned function; the handler should fully detach. + secondUnregister(); + const after = normalize(new Event('foo')); + // Without the handler, normalize falls back to default Event behavior (just enumerable getters). + expect(after.type).toBeUndefined(); + + // Restore for afterEach cleanup symmetry — register again so the outer unregister has work to do. + unregister = registerDomEventNormalizer(); + }); +}); diff --git a/packages/browser-utils/test/htmlTreeAsString.test.ts b/packages/browser-utils/test/htmlTreeAsString.test.ts new file mode 100644 index 000000000000..458ef61080a5 --- /dev/null +++ b/packages/browser-utils/test/htmlTreeAsString.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment jsdom + */ + +import { describe, expect, test } from 'vitest'; +import { htmlTreeAsString } from '../src/htmlTreeAsString'; + +describe('htmlTreeAsString()', () => { + test('returns for null/undefined', () => { + expect(htmlTreeAsString(null)).toBe(''); + expect(htmlTreeAsString(undefined)).toBe(''); + }); + + test('builds a query-selector style path for nested elements', () => { + document.body.innerHTML = + '
'; + + const button = document.querySelector('button')!; + const result = htmlTreeAsString(button); + + // Includes the tag, attribute matches, and ancestors. + expect(result).toContain('button'); + expect(result).toContain('section.content'); + expect(result).toContain('div#root'); + expect(result.split(' > ').length).toBeGreaterThanOrEqual(2); + }); + + test('honors data-sentry-component', () => { + document.body.innerHTML = '
inner
'; + const span = document.querySelector('span')!; + const result = htmlTreeAsString(span); + + expect(result).toContain('MyComponent'); + }); + + test('respects keyAttrs option', () => { + document.body.innerHTML = ''; + const button = document.querySelector('button')!; + + const result = htmlTreeAsString(button, { keyAttrs: ['data-test-id'] }); + expect(result).toContain('button[data-test-id="submit"]'); + }); + + test('truncates output by maxStringLength', () => { + document.body.innerHTML = `
x
`; + const span = document.querySelector('span')!; + const result = htmlTreeAsString(span, { maxStringLength: 20 }); + + // The full traversal would be much longer; the limit forces an early bail-out. + expect(result.length).toBeLessThanOrEqual(120); // truncation is best-effort + }); +}); diff --git a/packages/browser-utils/test/metrics/cls.test.ts b/packages/browser-utils/test/metrics/cls.test.ts index 55550d02f546..9a2c94da04d2 100644 --- a/packages/browser-utils/test/metrics/cls.test.ts +++ b/packages/browser-utils/test/metrics/cls.test.ts @@ -1,5 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import { _sendStandaloneClsSpan } from '../../src/metrics/cls'; import * as WebVitalUtils from '../../src/metrics/utils'; @@ -11,10 +12,13 @@ vi.mock('@sentry/core', async () => { browserPerformanceTimeOrigin: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), - htmlTreeAsString: vi.fn(), }; }); +vi.mock('../../src/htmlTreeAsString', () => ({ + htmlTreeAsString: vi.fn(), +})); + describe('_sendStandaloneClsSpan', () => { const mockSpan = { addEvent: vi.fn(), @@ -35,7 +39,7 @@ describe('_sendStandaloneClsSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); }); @@ -136,14 +140,14 @@ describe('_sendStandaloneClsSpan', () => { }; const pageloadSpanId = '789'; - vi.mocked(SentryCore.htmlTreeAsString) + vi.mocked(htmlTreeAsString) .mockReturnValueOnce('
') // for the name .mockReturnValueOnce('
') // for source 1 .mockReturnValueOnce(''); // for source 2 _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); - expect(SentryCore.htmlTreeAsString).toHaveBeenCalledTimes(3); + expect(htmlTreeAsString).toHaveBeenCalledTimes(3); expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ name: '
', transaction: 'test-transaction', diff --git a/packages/browser-utils/test/metrics/lcp.test.ts b/packages/browser-utils/test/metrics/lcp.test.ts index 634b9652f816..baa7cd5de052 100644 --- a/packages/browser-utils/test/metrics/lcp.test.ts +++ b/packages/browser-utils/test/metrics/lcp.test.ts @@ -1,5 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import { _sendStandaloneLcpSpan, isValidLcpMetric, MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; import * as WebVitalUtils from '../../src/metrics/utils'; @@ -9,10 +10,13 @@ vi.mock('@sentry/core', async () => { ...actual, browserPerformanceTimeOrigin: vi.fn(), getCurrentScope: vi.fn(), - htmlTreeAsString: vi.fn(), }; }); +vi.mock('../../src/htmlTreeAsString', () => ({ + htmlTreeAsString: vi.fn(), +})); + describe('isValidLcpMetric', () => { it('returns true for plausible lcp values', () => { expect(isValidLcpMetric(1)).toBe(true); @@ -43,7 +47,7 @@ describe('_sendStandaloneLcpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); }); diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 8b9325895e85..640a2ee95f71 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -1,5 +1,6 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { htmlTreeAsString } from '../../src/htmlTreeAsString'; import * as inpModule from '../../src/metrics/inp'; import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans'; @@ -11,7 +12,6 @@ vi.mock('@sentry/core', async () => { browserPerformanceTimeOrigin: vi.fn(), timestampInSeconds: vi.fn(), getCurrentScope: vi.fn(), - htmlTreeAsString: vi.fn(), startInactiveSpan: vi.fn(), getActiveSpan: vi.fn(), getRootSpan: vi.fn(), @@ -20,6 +20,10 @@ vi.mock('@sentry/core', async () => { }; }); +vi.mock('../../src/htmlTreeAsString', () => ({ + htmlTreeAsString: vi.fn(), +})); + // Mock WINDOW vi.mock('../../src/types', () => ({ WINDOW: { @@ -210,7 +214,7 @@ describe('_sendLcpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' }, @@ -296,7 +300,7 @@ describe('_sendClsSpan', () => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); - vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: { 'sentry.op': 'pageload' }, @@ -324,7 +328,7 @@ describe('_sendClsSpan', () => { toJSON: vi.fn(), }; - vi.mocked(SentryCore.htmlTreeAsString) + vi.mocked(htmlTreeAsString) .mockReturnValueOnce('
') // for the name .mockReturnValueOnce('
') // for source 1 .mockReturnValueOnce(''); // for source 2 @@ -377,7 +381,7 @@ describe('_sendInpSpan', () => { beforeEach(() => { vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); - vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('