diff --git a/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts index dfa1581d4704..59bfb2ea26e8 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/clickTargets/test.ts @@ -65,8 +65,10 @@ import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } expect(slowClickBreadcrumbs).toEqual([ { category: 'ui.slowClickDetected', + type: 'default', data: { endReason: 'timeout', + clickCount: 1, node: { attributes: expect.objectContaining({ id, diff --git a/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts new file mode 100644 index 000000000000..2d84eeb29ac3 --- /dev/null +++ b/packages/browser-integration-tests/suites/replay/slowClick/multiClick/test.ts @@ -0,0 +1,60 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; + +sentryTest('captures multi click when not detecting slow click', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.multiClick'); + }); + + await page.click('#mutationButtonImmediately', { clickCount: 4 }); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick'); + + expect(slowClickBreadcrumbs).toEqual([ + { + category: 'ui.multiClick', + type: 'default', + data: { + clickCount: 4, + metric: true, + node: { + attributes: { + id: 'mutationButtonImmediately', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* ******** ***********', + }, + nodeId: expect.any(Number), + url: 'http://sentry-test.io/index.html', + }, + message: 'body > button#mutationButtonImmediately', + timestamp: expect.any(Number), + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index 8a169a982aa8..deb394ebac2d 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -29,8 +29,6 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); }); - // Trigger this twice, sometimes this was flaky otherwise... - await page.click('#mutationButton'); await page.click('#mutationButton'); const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); @@ -40,8 +38,71 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe expect(slowClickBreadcrumbs).toEqual([ { category: 'ui.slowClickDetected', + type: 'default', + data: { + endReason: 'mutation', + clickCount: 1, + node: { + attributes: { + id: 'mutationButton', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* ********', + }, + nodeId: expect.any(Number), + timeAfterClickMs: expect.any(Number), + url: 'http://sentry-test.io/index.html', + }, + message: 'body > button#mutationButton', + timestamp: expect.any(Number), + }, + ]); + + expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000); + expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100); +}); + +sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + }); + + void page.click('#mutationButton', { clickCount: 4 }); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + const slowClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.slowClickDetected'); + const multiClickBreadcrumbs = breadcrumbs.filter(breadcrumb => breadcrumb.category === 'ui.multiClick'); + + expect(slowClickBreadcrumbs).toEqual([ + { + category: 'ui.slowClickDetected', + type: 'default', data: { endReason: 'mutation', + clickCount: 4, node: { attributes: { id: 'mutationButton', @@ -58,6 +119,7 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe timestamp: expect.any(Number), }, ]); + expect(multiClickBreadcrumbs.length).toEqual(0); expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeGreaterThan(3000); expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100); @@ -165,3 +227,55 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal }, ]); }); + +sentryTest('mouseDown events are considered', async ({ browserName, getLocalTestUrl, page }) => { + // This test seems to only be flakey on firefox + if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + await reqPromise0; + + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); + + await page.click('#mouseDownButton'); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + expect(breadcrumbs).toEqual([ + { + category: 'ui.click', + data: { + node: { + attributes: { + id: 'mouseDownButton', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* ******** ** ***** ****', + }, + nodeId: expect.any(Number), + }, + message: 'body > button#mouseDownButton', + timestamp: expect.any(Number), + type: 'default', + }, + ]); +}); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts index f7f705ce5670..a8e59752fc4a 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/scroll/test.ts @@ -89,8 +89,10 @@ sentryTest('late scroll triggers slow click', async ({ getLocalTestUrl, page }) expect(slowClickBreadcrumbs).toEqual([ { category: 'ui.slowClickDetected', + type: 'default', data: { endReason: 'timeout', + clickCount: 1, node: { attributes: { id: 'scrollLateButton', diff --git a/packages/browser-integration-tests/suites/replay/slowClick/template.html b/packages/browser-integration-tests/suites/replay/slowClick/template.html index 1cf757f7b974..f49c8b1d410d 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/template.html +++ b/packages/browser-integration-tests/suites/replay/slowClick/template.html @@ -18,6 +18,7 @@ + Link Link external @@ -69,6 +70,9 @@

Bottom

console.log('DONE'); }, 3001); }); + document.getElementById('mouseDownButton').addEventListener('mousedown', () => { + document.getElementById('out').innerHTML += 'mutationButton clicked
'; + }); // Do nothing on these elements document diff --git a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts index fef742681614..7e94e0b68f15 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/timeout/test.ts @@ -38,8 +38,10 @@ sentryTest('mutation after timeout results in slow click', async ({ getLocalTest expect(slowClickBreadcrumbs).toEqual([ { category: 'ui.slowClickDetected', + type: 'default', data: { endReason: 'timeout', + clickCount: 1, node: { attributes: { id: 'mutationButtonLate', @@ -93,8 +95,10 @@ sentryTest('console.log results in slow click', async ({ getLocalTestUrl, page } expect(slowClickBreadcrumbs).toEqual([ { category: 'ui.slowClickDetected', + type: 'default', data: { endReason: 'timeout', + clickCount: 1, node: { attributes: { id: 'consoleLogButton', diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index 1801c34a4e8e..120079ebb857 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -42,3 +42,5 @@ export const CONSOLE_ARG_MAX_SIZE = 5_000; export const SLOW_CLICK_THRESHOLD = 3_000; /* For scroll actions after a click, we only look for a very short time period to detect programmatic scrolling. */ export const SLOW_CLICK_SCROLL_TIMEOUT = 300; +/* Clicks in this time period are considered e.g. double/triple clicks. */ +export const MULTI_CLICK_TIMEOUT = 1_000; diff --git a/packages/replay/src/coreHandlers/handleClick.ts b/packages/replay/src/coreHandlers/handleClick.ts new file mode 100644 index 000000000000..5a75413b00d1 --- /dev/null +++ b/packages/replay/src/coreHandlers/handleClick.ts @@ -0,0 +1,306 @@ +import type { Breadcrumb } from '@sentry/types'; + +import { WINDOW } from '../constants'; +import type { MultiClickFrame, ReplayClickDetector, ReplayContainer, SlowClickConfig, SlowClickFrame } from '../types'; +import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; +import { getClickTargetNode } from './util/domUtils'; + +type ClickBreadcrumb = Breadcrumb & { + timestamp: number; +}; + +interface Click { + timestamp: number; + mutationAfter?: number; + scrollAfter?: number; + clickBreadcrumb: ClickBreadcrumb; + clickCount: number; + node: HTMLElement; +} + +/** Handle a click. */ +export function handleClick(clickDetector: ReplayClickDetector, clickBreadcrumb: Breadcrumb, node: HTMLElement): void { + clickDetector.handleClick(clickBreadcrumb, node); +} + +/** A click detector class that can be used to detect slow or rage clicks on elements. */ +export class ClickDetector implements ReplayClickDetector { + // protected for testing + protected _lastMutation = 0; + protected _lastScroll = 0; + + private _clicks: Click[] = []; + private _teardown: undefined | (() => void); + + private _multiClickTimeout: number; + private _threshold: number; + private _scollTimeout: number; + private _timeout: number; + private _ignoreSelector: string; + + private _replay: ReplayContainer; + private _checkClickTimeout?: ReturnType; + private _addBreadcrumbEvent: typeof addBreadcrumbEvent; + + public constructor( + replay: ReplayContainer, + slowClickConfig: SlowClickConfig, + // Just for easier testing + _addBreadcrumbEvent = addBreadcrumbEvent, + ) { + // We want everything in s, but options are in ms + this._timeout = slowClickConfig.timeout / 1000; + this._multiClickTimeout = slowClickConfig.multiClickTimeout / 1000; + this._threshold = slowClickConfig.threshold / 1000; + this._scollTimeout = slowClickConfig.scrollTimeout / 1000; + this._replay = replay; + this._ignoreSelector = slowClickConfig.ignoreSelector; + this._addBreadcrumbEvent = _addBreadcrumbEvent; + } + + /** Register click detection handlers on mutation or scroll. */ + public addListeners(): void { + const mutationHandler = (): void => { + this._lastMutation = nowInSeconds(); + }; + + const scrollHandler = (): void => { + this._lastScroll = nowInSeconds(); + }; + + const clickHandler = (event: MouseEvent): void => { + if (!event.target) { + return; + } + + const node = getClickTargetNode(event); + if (node) { + this._handleMultiClick(node as HTMLElement); + } + }; + + const obs = new MutationObserver(mutationHandler); + + obs.observe(WINDOW.document.documentElement, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + + WINDOW.addEventListener('scroll', scrollHandler, { passive: true }); + WINDOW.addEventListener('click', clickHandler, { passive: true }); + + this._teardown = () => { + WINDOW.removeEventListener('scroll', scrollHandler); + WINDOW.removeEventListener('click', clickHandler); + + obs.disconnect(); + this._clicks = []; + this._lastMutation = 0; + this._lastScroll = 0; + }; + } + + /** Clean up listeners. */ + public removeListeners(): void { + if (this._teardown) { + this._teardown(); + } + + if (this._checkClickTimeout) { + clearTimeout(this._checkClickTimeout); + } + } + + /** Handle a click */ + public handleClick(breadcrumb: Breadcrumb, node: HTMLElement): void { + if (ignoreElement(node, this._ignoreSelector) || !isClickBreadcrumb(breadcrumb)) { + return; + } + + const click = this._getClick(node); + + if (click) { + // this means a click on the same element was captured in the last 1s, so we consider this a multi click + return; + } + + const newClick: Click = { + timestamp: breadcrumb.timestamp, + clickBreadcrumb: breadcrumb, + // Set this to 0 so we know it originates from the click breadcrumb + clickCount: 0, + node, + }; + this._clicks.push(newClick); + + // If this is the first new click, set a timeout to check for multi clicks + if (this._clicks.length === 1) { + this._scheduleCheckClicks(); + } + } + + /** Count multiple clicks on elements. */ + private _handleMultiClick(node: HTMLElement): void { + const click = this._getClick(node); + + if (!click) { + return; + } + + click.clickCount++; + } + + /** Try to get an existing click on the given element. */ + private _getClick(node: HTMLElement): Click | undefined { + const now = nowInSeconds(); + + // Find any click on the same element in the last second + // If one exists, we consider this click as a double/triple/etc click + return this._clicks.find(click => click.node === node && now - click.timestamp < this._multiClickTimeout); + } + + /** Check the clicks that happened. */ + private _checkClicks(): void { + const timedOutClicks: Click[] = []; + + const now = nowInSeconds(); + + this._clicks.forEach(click => { + if (!click.mutationAfter && this._lastMutation) { + click.mutationAfter = click.timestamp <= this._lastMutation ? this._lastMutation - click.timestamp : undefined; + } + if (!click.scrollAfter && this._lastScroll) { + click.scrollAfter = click.timestamp <= this._lastScroll ? this._lastScroll - click.timestamp : undefined; + } + + // If an action happens after the multi click threshold, we can skip waiting and handle the click right away + const actionTime = click.scrollAfter || click.mutationAfter || 0; + if (actionTime && actionTime >= this._multiClickTimeout) { + timedOutClicks.push(click); + return; + } + + if (click.timestamp + this._timeout <= now) { + timedOutClicks.push(click); + } + }); + + // Remove "old" clicks + for (const click of timedOutClicks) { + this._generateBreadcrumbs(click); + + const pos = this._clicks.indexOf(click); + if (pos !== -1) { + this._clicks.splice(pos, 1); + } + } + + // Trigger new check, unless no clicks left + if (this._clicks.length) { + this._scheduleCheckClicks(); + } + } + + /** Generate matching breadcrumb(s) for the click. */ + private _generateBreadcrumbs(click: Click): void { + const replay = this._replay; + const hadScroll = click.scrollAfter && click.scrollAfter <= this._scollTimeout; + const hadMutation = click.mutationAfter && click.mutationAfter <= this._threshold; + + const isSlowClick = !hadScroll && !hadMutation; + const { clickCount, clickBreadcrumb } = click; + + // Slow click + if (isSlowClick) { + // If `mutationAfter` is set, it means a mutation happened after the threshold, but before the timeout + // If not, it means we just timed out without scroll & mutation + const timeAfterClickMs = Math.min(click.mutationAfter || this._timeout, this._timeout) * 1000; + const endReason = timeAfterClickMs < this._timeout * 1000 ? 'mutation' : 'timeout'; + + const breadcrumb: SlowClickFrame = { + type: 'default', + message: clickBreadcrumb.message, + timestamp: clickBreadcrumb.timestamp, + category: 'ui.slowClickDetected', + data: { + ...clickBreadcrumb.data, + url: WINDOW.location.href, + route: replay.getCurrentRoute(), + timeAfterClickMs, + endReason, + // If clickCount === 0, it means multiClick was not correctly captured here + // - we still want to send 1 in this case + clickCount: clickCount || 1, + }, + }; + + this._addBreadcrumbEvent(replay, breadcrumb); + return; + } + + // Multi click + if (clickCount > 1) { + const breadcrumb: MultiClickFrame = { + type: 'default', + message: clickBreadcrumb.message, + timestamp: clickBreadcrumb.timestamp, + category: 'ui.multiClick', + data: { + ...clickBreadcrumb.data, + url: WINDOW.location.href, + route: replay.getCurrentRoute(), + clickCount, + metric: true, + }, + }; + + this._addBreadcrumbEvent(replay, breadcrumb); + } + } + + /** Schedule to check current clicks. */ + private _scheduleCheckClicks(): void { + this._checkClickTimeout = setTimeout(() => this._checkClicks(), 1000); + } +} + +const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT']; + +/** exported for tests only */ +export function ignoreElement(node: HTMLElement, ignoreSelector: string): boolean { + if (!SLOW_CLICK_TAGS.includes(node.tagName)) { + return true; + } + + // If tag, we only want to consider input[type='submit'] & input[type='button'] + if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) { + return true; + } + + // If tag, detect special variants that may not lead to an action + // If target !== _self, we may open the link somewhere else, which would lead to no action + // Also, when downloading a file, we may not leave the page, but still not trigger an action + if ( + node.tagName === 'A' && + (node.hasAttribute('download') || (node.hasAttribute('target') && node.getAttribute('target') !== '_self')) + ) { + return true; + } + + if (ignoreSelector && node.matches(ignoreSelector)) { + return true; + } + + return false; +} + +function isClickBreadcrumb(breadcrumb: Breadcrumb): breadcrumb is ClickBreadcrumb { + return !!(breadcrumb.data && typeof breadcrumb.data.nodeId === 'number' && breadcrumb.timestamp); +} + +// This is good enough for us, and is easier to test/mock than `timestampInSeconds` +function nowInSeconds(): number { + return Date.now() / 1000; +} diff --git a/packages/replay/src/coreHandlers/handleDom.ts b/packages/replay/src/coreHandlers/handleDom.ts index 54ab7ec8bb09..71fb211ee3fe 100644 --- a/packages/replay/src/coreHandlers/handleDom.ts +++ b/packages/replay/src/coreHandlers/handleDom.ts @@ -3,32 +3,17 @@ import { NodeType } from '@sentry-internal/rrweb-snapshot'; import type { Breadcrumb } from '@sentry/types'; import { htmlTreeAsString } from '@sentry/utils'; -import { SLOW_CLICK_SCROLL_TIMEOUT, SLOW_CLICK_THRESHOLD } from '../constants'; -import type { ReplayContainer, SlowClickConfig } from '../types'; +import type { ReplayContainer } from '../types'; import { createBreadcrumb } from '../util/createBreadcrumb'; -import { detectSlowClick } from './handleSlowClick'; +import { handleClick } from './handleClick'; import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; +import type { DomHandlerData } from './util/domUtils'; +import { getClickTargetNode, getTargetNode } from './util/domUtils'; import { getAttributesToRecord } from './util/getAttributesToRecord'; -export interface DomHandlerData { - name: string; - event: Node | { target: EventTarget }; -} - export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHandlerData) => void = ( replay: ReplayContainer, ) => { - const { slowClickTimeout, slowClickIgnoreSelectors } = replay.getOptions(); - - const slowClickConfig: SlowClickConfig | undefined = slowClickTimeout - ? { - threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout), - timeout: slowClickTimeout, - scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT, - ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '', - } - : undefined; - return (handlerData: DomHandlerData): void => { if (!replay.isEnabled()) { return; @@ -43,11 +28,10 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: DomHa const isClick = handlerData.name === 'click'; const event = isClick && (handlerData.event as PointerEvent); // Ignore clicks if ctrl/alt/meta keys are held down as they alter behavior of clicks (e.g. open in new tab) - if (isClick && slowClickConfig && event && !event.altKey && !event.metaKey && !event.ctrlKey) { - detectSlowClick( - replay, - slowClickConfig, - result as Breadcrumb & { timestamp: number }, + if (isClick && replay.clickDetector && event && !event.altKey && !event.metaKey && !event.ctrlKey) { + handleClick( + replay.clickDetector, + result as Breadcrumb & { timestamp: number; data: { nodeId: number } }, getClickTargetNode(handlerData.event) as HTMLElement, ); } @@ -118,32 +102,3 @@ function getDomTarget(handlerData: DomHandlerData): { target: Node | INode | nul function isRrwebNode(node: EventTarget): node is INode { return '__sn' in node; } - -function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null { - if (isEventWithTarget(event)) { - return event.target as Node | null; - } - - return event; -} - -const INTERACTIVE_SELECTOR = 'button,a'; - -// For clicks, we check if the target is inside of a button or link -// If so, we use this as the target instead -// This is useful because if you click on the image in , -// The target will be the image, not the button, which we don't want here -function getClickTargetNode(event: DomHandlerData['event']): Node | INode | null { - const target = getTargetNode(event); - - if (!target || !(target instanceof Element)) { - return target; - } - - const closestInteractive = target.closest(INTERACTIVE_SELECTOR); - return closestInteractive || target; -} - -function isEventWithTarget(event: unknown): event is { target: EventTarget | null } { - return typeof event === 'object' && !!event && 'target' in event; -} diff --git a/packages/replay/src/coreHandlers/handleSlowClick.ts b/packages/replay/src/coreHandlers/handleSlowClick.ts deleted file mode 100644 index c939a990f87a..000000000000 --- a/packages/replay/src/coreHandlers/handleSlowClick.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Breadcrumb } from '@sentry/types'; - -import { WINDOW } from '../constants'; -import type { ReplayContainer, SlowClickConfig } from '../types'; -import { addBreadcrumbEvent } from './util/addBreadcrumbEvent'; - -type ClickBreadcrumb = Breadcrumb & { - timestamp: number; -}; - -/** - * Detect a slow click on a button/a tag, - * and potentially create a corresponding breadcrumb. - */ -export function detectSlowClick( - replay: ReplayContainer, - config: SlowClickConfig, - clickBreadcrumb: ClickBreadcrumb, - node: HTMLElement, -): void { - if (ignoreElement(node, config)) { - return; - } - - /* - We consider a slow click a click on a button/a, which does not trigger one of: - - DOM mutation - - Scroll (within 100ms) - Within the given threshold time. - After time timeout time, we stop listening and mark it as a slow click anyhow. - */ - - let cleanup: () => void = () => { - // replaced further down - }; - - // After timeout time, def. consider this a slow click, and stop watching for mutations - const timeout = setTimeout(() => { - handleSlowClick(replay, clickBreadcrumb, config.timeout, 'timeout'); - cleanup(); - }, config.timeout); - - const mutationHandler = (): void => { - maybeHandleSlowClick(replay, clickBreadcrumb, config.threshold, config.timeout, 'mutation'); - cleanup(); - }; - - const scrollHandler = (): void => { - maybeHandleSlowClick(replay, clickBreadcrumb, config.scrollTimeout, config.timeout, 'scroll'); - cleanup(); - }; - - const obs = new MutationObserver(mutationHandler); - - obs.observe(WINDOW.document.documentElement, { - attributes: true, - characterData: true, - childList: true, - subtree: true, - }); - - WINDOW.addEventListener('scroll', scrollHandler); - - // Stop listening to scroll timeouts early - const scrollTimeout = setTimeout(() => { - WINDOW.removeEventListener('scroll', scrollHandler); - }, config.scrollTimeout); - - cleanup = (): void => { - clearTimeout(timeout); - clearTimeout(scrollTimeout); - obs.disconnect(); - WINDOW.removeEventListener('scroll', scrollHandler); - }; -} - -function maybeHandleSlowClick( - replay: ReplayContainer, - clickBreadcrumb: ClickBreadcrumb, - threshold: number, - timeout: number, - endReason: string, -): boolean { - const now = Date.now(); - const timeAfterClickMs = now - clickBreadcrumb.timestamp * 1000; - - if (timeAfterClickMs > threshold) { - handleSlowClick(replay, clickBreadcrumb, Math.min(timeAfterClickMs, timeout), endReason); - return true; - } - - return false; -} - -function handleSlowClick( - replay: ReplayContainer, - clickBreadcrumb: ClickBreadcrumb, - timeAfterClickMs: number, - endReason: string, -): void { - const breadcrumb = { - message: clickBreadcrumb.message, - timestamp: clickBreadcrumb.timestamp, - category: 'ui.slowClickDetected', - data: { - ...clickBreadcrumb.data, - url: WINDOW.location.href, - // TODO FN: add parametrized route, when possible - timeAfterClickMs, - endReason, - }, - }; - - addBreadcrumbEvent(replay, breadcrumb); -} - -const SLOW_CLICK_TAGS = ['A', 'BUTTON', 'INPUT']; - -/** exported for tests only */ -export function ignoreElement(node: HTMLElement, config: SlowClickConfig): boolean { - if (!SLOW_CLICK_TAGS.includes(node.tagName)) { - return true; - } - - // If tag, we only want to consider input[type='submit'] & input[type='button'] - if (node.tagName === 'INPUT' && !['submit', 'button'].includes(node.getAttribute('type') || '')) { - return true; - } - - // If tag, detect special variants that may not lead to an action - // If target !== _self, we may open the link somewhere else, which would lead to no action - // Also, when downloading a file, we may not leave the page, but still not trigger an action - if ( - node.tagName === 'A' && - (node.hasAttribute('download') || (node.hasAttribute('target') && node.getAttribute('target') !== '_self')) - ) { - return true; - } - - if (config.ignoreSelector && node.matches(config.ignoreSelector)) { - return true; - } - - return false; -} diff --git a/packages/replay/src/coreHandlers/util/domUtils.ts b/packages/replay/src/coreHandlers/util/domUtils.ts new file mode 100644 index 000000000000..6091dc7fe837 --- /dev/null +++ b/packages/replay/src/coreHandlers/util/domUtils.ts @@ -0,0 +1,38 @@ +import type { INode } from '@sentry-internal/rrweb-snapshot'; + +export interface DomHandlerData { + name: string; + event: Node | { target: EventTarget }; +} + +const INTERACTIVE_SELECTOR = 'button,a'; + +/** + * For clicks, we check if the target is inside of a button or link + * If so, we use this as the target instead + * This is useful because if you click on the image in , + * The target will be the image, not the button, which we don't want here + */ +export function getClickTargetNode(event: DomHandlerData['event'] | MouseEvent): Node | INode | null { + const target = getTargetNode(event); + + if (!target || !(target instanceof Element)) { + return target; + } + + const closestInteractive = target.closest(INTERACTIVE_SELECTOR); + return closestInteractive || target; +} + +/** Get the event target node. */ +export function getTargetNode(event: Node | { target: EventTarget | null }): Node | INode | null { + if (isEventWithTarget(event)) { + return event.target as Node | null; + } + + return event; +} + +function isEventWithTarget(event: unknown): event is { target: EventTarget | null } { + return typeof event === 'object' && !!event && 'target' in event; +} diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index a00b96f23c19..acb2980e608c 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -7,10 +7,14 @@ import { logger } from '@sentry/utils'; import { BUFFER_CHECKOUT_TIME, MAX_SESSION_LIFE, + MULTI_CLICK_TIMEOUT, SESSION_IDLE_EXPIRE_DURATION, SESSION_IDLE_PAUSE_DURATION, + SLOW_CLICK_SCROLL_TIMEOUT, + SLOW_CLICK_THRESHOLD, WINDOW, } from './constants'; +import { ClickDetector } from './coreHandlers/handleClick'; import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent'; import { setupPerformanceObserver } from './coreHandlers/performanceObserver'; import { createEventBuffer } from './eventBuffer'; @@ -31,6 +35,7 @@ import type { ReplayPluginOptions, SendBufferedReplayOptions, Session, + SlowClickConfig, Timeouts, } from './types'; import { addEvent } from './util/addEvent'; @@ -60,6 +65,8 @@ export class ReplayContainer implements ReplayContainerInterface { public session: Session | undefined; + public clickDetector: ClickDetector | undefined; + /** * Recording can happen in one of three modes: * - session: Record the whole session, sending it continuously @@ -159,6 +166,22 @@ export class ReplayContainer implements ReplayContainerInterface { // ... per 5s 5, ); + + const { slowClickTimeout, slowClickIgnoreSelectors } = this.getOptions(); + + const slowClickConfig: SlowClickConfig | undefined = slowClickTimeout + ? { + threshold: Math.min(SLOW_CLICK_THRESHOLD, slowClickTimeout), + timeout: slowClickTimeout, + scrollTimeout: SLOW_CLICK_SCROLL_TIMEOUT, + ignoreSelector: slowClickIgnoreSelectors ? slowClickIgnoreSelectors.join(',') : '', + multiClickTimeout: MULTI_CLICK_TIMEOUT, + } + : undefined; + + if (slowClickConfig) { + this.clickDetector = new ClickDetector(this, slowClickConfig); + } } /** Get the event context. */ @@ -737,6 +760,10 @@ export class ReplayContainer implements ReplayContainerInterface { WINDOW.addEventListener('focus', this._handleWindowFocus); WINDOW.addEventListener('keydown', this._handleKeyboardEvent); + if (this.clickDetector) { + this.clickDetector.addListeners(); + } + // There is no way to remove these listeners, so ensure they are only added once if (!this._hasInitializedCoreListeners) { addGlobalListeners(this); @@ -766,6 +793,10 @@ export class ReplayContainer implements ReplayContainerInterface { WINDOW.removeEventListener('focus', this._handleWindowFocus); WINDOW.removeEventListener('keydown', this._handleKeyboardEvent); + if (this.clickDetector) { + this.clickDetector.removeListeners(); + } + if (this._performanceObserver) { this._performanceObserver.disconnect(); this._performanceObserver = null; diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts index 2ec6e18346ee..f058b2c9011a 100644 --- a/packages/replay/src/types/replay.ts +++ b/packages/replay/src/types/replay.ts @@ -1,4 +1,5 @@ import type { + Breadcrumb, FetchBreadcrumbHint, HandlerDataFetch, ReplayRecordingData, @@ -407,8 +408,15 @@ export interface SendBufferedReplayOptions { continueRecording?: boolean; } +export interface ReplayClickDetector { + addListeners(): void; + removeListeners(): void; + handleClick(breadcrumb: Breadcrumb, node: HTMLElement): void; +} + export interface ReplayContainer { eventBuffer: EventBuffer | null; + clickDetector: ReplayClickDetector | undefined; performanceEvents: AllPerformanceEntry[]; session: Session | undefined; recordingMode: ReplayRecordingMode; @@ -468,4 +476,5 @@ export interface SlowClickConfig { timeout: number; scrollTimeout: number; ignoreSelector: string; + multiClickTimeout: number; } diff --git a/packages/replay/src/types/replayFrame.ts b/packages/replay/src/types/replayFrame.ts index f0107fdbf77a..379dbab91605 100644 --- a/packages/replay/src/types/replayFrame.ts +++ b/packages/replay/src/types/replayFrame.ts @@ -88,14 +88,28 @@ interface FocusFrame extends BaseBreadcrumbFrame { interface SlowClickFrameData extends ClickFrameData { url: string; - timeAfterClickFs: number; + route?: string; + timeAfterClickMs: number; endReason: string; + clickCount: number; } -interface SlowClickFrame extends BaseBreadcrumbFrame { +export interface SlowClickFrame extends BaseBreadcrumbFrame { category: 'ui.slowClickDetected'; data: SlowClickFrameData; } +interface MultiClickFrameData extends ClickFrameData { + url: string; + route?: string; + clickCount: number; + metric: true; +} + +export interface MultiClickFrame extends BaseBreadcrumbFrame { + category: 'ui.multiClick'; + data: MultiClickFrameData; +} + interface OptionFrame { blockAllMedia: boolean; errorSampleRate: number; @@ -118,6 +132,7 @@ export type BreadcrumbFrame = | BlurFrame | FocusFrame | SlowClickFrame + | MultiClickFrame | MutationFrame | BaseBreadcrumbFrame; diff --git a/packages/replay/test/unit/coreHandlers/handleClick.test.ts b/packages/replay/test/unit/coreHandlers/handleClick.test.ts new file mode 100644 index 000000000000..8b06e4683656 --- /dev/null +++ b/packages/replay/test/unit/coreHandlers/handleClick.test.ts @@ -0,0 +1,542 @@ +import type { Breadcrumb } from '@sentry/types'; + +import { BASE_TIMESTAMP } from '../..'; +import { ClickDetector, ignoreElement } from '../../../src/coreHandlers/handleClick'; +import type { ReplayContainer } from '../../../src/types'; + +jest.useFakeTimers(); + +describe('Unit | coreHandlers | handleClick', () => { + describe('ClickDetector', () => { + beforeEach(() => { + jest.setSystemTime(BASE_TIMESTAMP); + }); + + test('it captures a single click', async () => { + const replay = { + getCurrentRoute: () => 'test-route', + } as ReplayContainer; + + const mockAddBreadcrumbEvent = jest.fn(); + + const detector = new ClickDetector( + replay, + { + threshold: 1_000, + timeout: 3_000, + scrollTimeout: 200, + ignoreSelector: '', + multiClickTimeout: 1_000, + }, + mockAddBreadcrumbEvent, + ); + + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(1_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(1_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(1_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, { + category: 'ui.slowClickDetected', + type: 'default', + data: { + clickCount: 1, + endReason: 'timeout', + nodeId: 1, + route: 'test-route', + timeAfterClickMs: 3000, + url: 'http://localhost/', + }, + message: undefined, + timestamp: expect.any(Number), + }); + + jest.advanceTimersByTime(5_000); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + }); + + test('it groups multiple clicks together', async () => { + const replay = { + getCurrentRoute: () => 'test-route', + } as ReplayContainer; + + const mockAddBreadcrumbEvent = jest.fn(); + + const detector = new ClickDetector( + replay, + { + threshold: 1_000, + timeout: 3_000, + scrollTimeout: 200, + ignoreSelector: '', + multiClickTimeout: 1_000, + }, + mockAddBreadcrumbEvent, + ); + + const breadcrumb1: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const breadcrumb2: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000 + 0.2, + data: { + nodeId: 1, + }, + }; + const breadcrumb3: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000 + 0.6, + data: { + nodeId: 1, + }, + }; + const breadcrumb4: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000 + 2, + data: { + nodeId: 1, + }, + }; + const breadcrumb5: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000 + 2.9, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb1, node); + + detector.handleClick(breadcrumb2, node); + + detector.handleClick(breadcrumb3, node); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(2_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + detector.handleClick(breadcrumb4, node); + detector.handleClick(breadcrumb5, node); + + jest.advanceTimersByTime(1_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, { + category: 'ui.slowClickDetected', + type: 'default', + data: { + // count is not actually correct, because this is identified by a different click handler + clickCount: 1, + endReason: 'timeout', + nodeId: 1, + route: 'test-route', + timeAfterClickMs: 3000, + url: 'http://localhost/', + }, + message: undefined, + timestamp: expect.any(Number), + }); + + jest.advanceTimersByTime(2_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, { + category: 'ui.slowClickDetected', + type: 'default', + data: { + // count is not actually correct, because this is identified by a different click handler + clickCount: 1, + endReason: 'timeout', + nodeId: 1, + route: 'test-route', + timeAfterClickMs: 3000, + url: 'http://localhost/', + }, + message: undefined, + timestamp: expect.any(Number), + }); + + jest.advanceTimersByTime(5_000); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(2); + }); + + test('it captures clicks on different elements', async () => { + const replay = { + getCurrentRoute: () => 'test-route', + } as ReplayContainer; + + const mockAddBreadcrumbEvent = jest.fn(); + + const detector = new ClickDetector( + replay, + { + threshold: 1_000, + timeout: 3_000, + scrollTimeout: 200, + ignoreSelector: '', + multiClickTimeout: 1_000, + }, + mockAddBreadcrumbEvent, + ); + + const breadcrumb1: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const breadcrumb2: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 2, + }, + }; + const breadcrumb3: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 3, + }, + }; + const node1 = document.createElement('button'); + const node2 = document.createElement('button'); + const node3 = document.createElement('button'); + detector.handleClick(breadcrumb1, node1); + detector.handleClick(breadcrumb2, node2); + detector.handleClick(breadcrumb3, node3); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(3_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(3); + + jest.advanceTimersByTime(5_000); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(3); + }); + + test('it ignores clicks on ignored elements', async () => { + const replay = { + getCurrentRoute: () => 'test-route', + } as ReplayContainer; + + const mockAddBreadcrumbEvent = jest.fn(); + + const detector = new ClickDetector( + replay, + { + threshold: 1_000, + timeout: 3_000, + scrollTimeout: 200, + ignoreSelector: '', + multiClickTimeout: 1_000, + }, + mockAddBreadcrumbEvent, + ); + + const breadcrumb1: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const breadcrumb2: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 2, + }, + }; + const breadcrumb3: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 3, + }, + }; + const node1 = document.createElement('div'); + const node2 = document.createElement('div'); + const node3 = document.createElement('div'); + detector.handleClick(breadcrumb1, node1); + detector.handleClick(breadcrumb2, node2); + detector.handleClick(breadcrumb3, node3); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(3_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + }); + + describe('mutations', () => { + let detector: ClickDetector; + let mockAddBreadcrumbEvent = jest.fn(); + + const replay = { + getCurrentRoute: () => 'test-route', + } as ReplayContainer; + + beforeEach(() => { + jest.setSystemTime(BASE_TIMESTAMP); + + mockAddBreadcrumbEvent = jest.fn(); + + detector = new ClickDetector( + replay, + { + threshold: 1_000, + timeout: 3_000, + scrollTimeout: 200, + ignoreSelector: '', + multiClickTimeout: 1_000, + }, + mockAddBreadcrumbEvent, + ); + }); + + test('it does not consider clicks with mutation before threshold as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(500); + + // Pretend a mutation happend + detector['_lastMutation'] = BASE_TIMESTAMP / 1000 + 0.5; + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(3_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + }); + + test('it considers clicks with mutation after threshold as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(1_000); + + // Pretend a mutation happend + detector['_lastMutation'] = BASE_TIMESTAMP / 1000 + 2; + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(3_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, { + category: 'ui.slowClickDetected', + type: 'default', + data: { + clickCount: 1, + endReason: 'mutation', + nodeId: 1, + route: 'test-route', + timeAfterClickMs: 2000, + url: 'http://localhost/', + }, + message: undefined, + timestamp: expect.any(Number), + }); + + jest.advanceTimersByTime(5_000); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + }); + + test('it caps timeout', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(1_000); + + // Pretend a mutation happend + detector['_lastMutation'] = BASE_TIMESTAMP / 1000 + 5; + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(5_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, { + category: 'ui.slowClickDetected', + type: 'default', + data: { + clickCount: 1, + endReason: 'timeout', + nodeId: 1, + route: 'test-route', + timeAfterClickMs: 3000, + url: 'http://localhost/', + }, + message: undefined, + timestamp: expect.any(Number), + }); + + jest.advanceTimersByTime(5_000); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + }); + }); + + describe('scroll', () => { + let detector: ClickDetector; + let mockAddBreadcrumbEvent = jest.fn(); + + const replay = { + getCurrentRoute: () => 'test-route', + } as ReplayContainer; + + beforeEach(() => { + jest.setSystemTime(BASE_TIMESTAMP); + + mockAddBreadcrumbEvent = jest.fn(); + + detector = new ClickDetector( + replay, + { + threshold: 1_000, + timeout: 3_000, + scrollTimeout: 200, + ignoreSelector: '', + multiClickTimeout: 1_000, + }, + mockAddBreadcrumbEvent, + ); + }); + + test('it does not consider clicks with scroll before threshold as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(100); + + // Pretend a mutation happend + detector['_lastScroll'] = BASE_TIMESTAMP / 1000 + 0.15; + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(3_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + }); + + test('it considers clicks with scroll after threshold as slow click', async () => { + const breadcrumb: Breadcrumb = { + timestamp: BASE_TIMESTAMP / 1000, + data: { + nodeId: 1, + }, + }; + const node = document.createElement('button'); + detector.handleClick(breadcrumb, node); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(300); + + // Pretend a mutation happend + detector['_lastScroll'] = BASE_TIMESTAMP / 1000 + 0.3; + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(3_000); + + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledWith(replay, { + category: 'ui.slowClickDetected', + type: 'default', + data: { + clickCount: 1, + endReason: 'timeout', + nodeId: 1, + route: 'test-route', + timeAfterClickMs: 3000, + url: 'http://localhost/', + }, + message: undefined, + timestamp: expect.any(Number), + }); + + jest.advanceTimersByTime(5_000); + expect(mockAddBreadcrumbEvent).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('ignoreElement', () => { + it.each([ + ['div', {}, true], + ['button', {}, false], + ['a', {}, false], + ['input', {}, true], + ['input', { type: 'text' }, true], + ['input', { type: 'button' }, false], + ['input', { type: 'submit' }, false], + ['a', { target: '_self' }, false], + ['a', { target: '_blank' }, true], + ['a', { download: '' }, true], + ['a', { href: 'xx' }, false], + ])('it works with <%s> & %p', (tagName, attributes, expected) => { + const node = document.createElement(tagName); + Object.entries(attributes).forEach(([key, value]) => { + node.setAttribute(key, value); + }); + expect(ignoreElement(node, '')).toBe(expected); + }); + + test('it ignored selectors matching ignoreSelector', () => { + const button = document.createElement('button'); + const a = document.createElement('a'); + + expect(ignoreElement(button, 'button')).toBe(true); + expect(ignoreElement(a, 'button')).toBe(false); + }); + }); +}); diff --git a/packages/replay/test/unit/coreHandlers/handleDom.test.ts b/packages/replay/test/unit/coreHandlers/handleDom.test.ts index dc1fff0b5ff2..99fa5dc1e367 100644 --- a/packages/replay/test/unit/coreHandlers/handleDom.test.ts +++ b/packages/replay/test/unit/coreHandlers/handleDom.test.ts @@ -1,5 +1,5 @@ -import type { DomHandlerData } from '../../../src/coreHandlers/handleDom'; import { handleDom } from '../../../src/coreHandlers/handleDom'; +import type { DomHandlerData } from '../../../src/coreHandlers/util/domUtils'; describe('Unit | coreHandlers | handleDom', () => { test('it works with a basic click event on a div', () => { diff --git a/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts b/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts deleted file mode 100644 index 2d0922272115..000000000000 --- a/packages/replay/test/unit/coreHandlers/handleSlowClick.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ignoreElement } from '../../../src/coreHandlers/handleSlowClick'; -import type { SlowClickConfig } from '../../../src/types'; - -describe('Unit | coreHandlers | handleSlowClick', () => { - describe('ignoreElement', () => { - it.each([ - ['div', {}, true], - ['button', {}, false], - ['a', {}, false], - ['input', {}, true], - ['input', { type: 'text' }, true], - ['input', { type: 'button' }, false], - ['input', { type: 'submit' }, false], - ['a', { target: '_self' }, false], - ['a', { target: '_blank' }, true], - ['a', { download: '' }, true], - ['a', { href: 'xx' }, false], - ])('it works with <%s> & %p', (tagName, attributes, expected) => { - const node = document.createElement(tagName); - Object.entries(attributes).forEach(([key, value]) => { - node.setAttribute(key, value); - }); - expect(ignoreElement(node, {} as SlowClickConfig)).toBe(expected); - }); - - test('it ignored selectors matching ignoreSelector', () => { - const button = document.createElement('button'); - const a = document.createElement('a'); - - expect(ignoreElement(button, { ignoreSelector: 'button' } as SlowClickConfig)).toBe(true); - expect(ignoreElement(a, { ignoreSelector: 'button' } as SlowClickConfig)).toBe(false); - }); - }); -});