Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions packages/core/src/utils/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ type SimpleNode = {

/**
* Given a child DOM element, returns a query-selector statement describing that
* and its ancestors
* and its ancestors, optionally prefixed with data-sentry-label if found on an ancestor
* e.g. [HTMLElement] => body > div > input#foo.btn[name=baz]
* e.g. [HTMLElement] => [data-sentry-label="MyLabel"] div.container > button
* @returns generated DOM path
*/
export function htmlTreeAsString(
Expand All @@ -30,7 +31,7 @@ export function htmlTreeAsString(
try {
let currentElem = elem as SimpleNode;
const MAX_TRAVERSE_HEIGHT = 5;
const out = [];
const out: string[] = [];
let height = 0;
let len = 0;
const separator = ' > ';
Expand All @@ -55,7 +56,18 @@ export function htmlTreeAsString(
currentElem = currentElem.parentNode;
}

return out.reverse().join(separator);
const cssSelector = out.reverse().join(separator);

if (cssSelector.includes('[data-sentry-label="')) {
return cssSelector;
}

const sentryLabel = _getSentryLabel(elem);
if (sentryLabel) {
return `[data-sentry-label="${sentryLabel}"] ${cssSelector}`;
}

return cssSelector;
} catch {
return '<unknown>';
}
Expand Down Expand Up @@ -84,6 +96,9 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string {
if (WINDOW.HTMLElement) {
// 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['sentryLabel']) {
return `[data-sentry-label="${elem.dataset['sentryLabel']}"]`;
}
if (elem.dataset['sentryComponent']) {
return elem.dataset['sentryComponent'];
}
Expand Down Expand Up @@ -128,6 +143,27 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string {
return out.join('');
}

/**
* Searches for the data-sentry-label attribute up the DOM tree.
* @returns The value of the first data-sentry-label found, or null if not found
*/
function _getSentryLabel(elem: unknown): string | null {
const MAX_LABEL_TRAVERSE_HEIGHT = 15;
let labelElem = elem as SimpleNode;

for (let i = 0; i < MAX_LABEL_TRAVERSE_HEIGHT && labelElem; i++) {
// @ts-expect-error WINDOW has HTMLElement
if (WINDOW.HTMLElement && labelElem instanceof HTMLElement && labelElem.dataset) {
if (labelElem.dataset['sentryLabel']) {
return labelElem.dataset['sentryLabel'];
}
}
labelElem = labelElem.parentNode;
}

return null;
}

/**
* A safe form of location.href
*/
Expand Down
81 changes: 80 additions & 1 deletion packages/core/test/lib/utils/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { htmlTreeAsString } from '../../../src/utils/browser';
beforeAll(() => {
const dom = new JSDOM();
global.document = dom.window.document;
global.HTMLElement = new JSDOM().window.HTMLElement;
global.HTMLElement = dom.window.HTMLElement;
});

describe('htmlTreeAsString', () => {
Expand Down Expand Up @@ -73,4 +73,83 @@ describe('htmlTreeAsString', () => {
'div#main-cta > div.container > button.bg-blue-500.hover:bg-blue-700.text-white.hover:text-blue-100',
);
});

describe('data-sentry-label support', () => {
it('returns data-sentry-label when element has the attribute directly', () => {
const el = document.createElement('div');
el.innerHTML = '<button data-sentry-label="SubmitButton" class="btn" />';
document.body.appendChild(el);

expect(htmlTreeAsString(document.querySelector('button'))).toBe(
'body > div > [data-sentry-label="SubmitButton"]',
);
});

it('includes data-sentry-label from ancestor element in the path', () => {
const el = document.createElement('div');
el.innerHTML = `<div data-sentry-label="LoginForm">
<div class="form-group">
<button id="submit-btn" class="btn" />
</div>
</div>`;
document.body.appendChild(el);

expect(htmlTreeAsString(document.getElementById('submit-btn'))).toBe(
'div > [data-sentry-label="LoginForm"] > div.form-group > button#submit-btn.btn',
);
});

it('finds data-sentry-label on a distant ancestor within traverse limit', () => {
const el = document.createElement('div');
el.innerHTML = `<div data-sentry-label="DeepForm">
<div class="level-1">
<div class="level-2">
<div class="level-3">
<div class="level-4">
<div class="level-5">
<button id="deep-btn" />
</div>
</div>
</div>
</div>
</div>
</div>`;
document.body.appendChild(el);

const result = htmlTreeAsString(document.getElementById('deep-btn'));
expect(result).toContain('[data-sentry-label="DeepForm"]');
});

it('does not add prefix if data-sentry-label is already in cssSelector path', () => {
const el = document.createElement('div');
el.innerHTML = `<div data-sentry-label="OuterLabel">
<div data-sentry-label="InnerLabel">
<button id="btn" />
</div>
</div>`;
document.body.appendChild(el);

expect(htmlTreeAsString(document.getElementById('btn'))).toBe('[data-sentry-label="InnerLabel"] > button#btn');
});

it('returns normal cssSelector when no data-sentry-label exists', () => {
const el = document.createElement('div');
el.innerHTML = `<div class="container">
<button id="no-label-btn" class="btn" />
</div>`;
document.body.appendChild(el);

expect(htmlTreeAsString(document.getElementById('no-label-btn'))).toBe(
'body > div > div.container > button#no-label-btn.btn',
);
});

it('prioritizes data-sentry-label over data-sentry-component', () => {
const el = document.createElement('div');
el.innerHTML = '<button data-sentry-component="MyComponent" data-sentry-label="MyLabel" class="btn" />';
document.body.appendChild(el);

expect(htmlTreeAsString(document.querySelector('button'))).toBe('body > div > [data-sentry-label="MyLabel"]');
});
});
});