feat(plugin-autocapture-browser): add shadow to hierarchy - #1937
feat(plugin-autocapture-browser): add shadow to hierarchy#1937daniel-graham-amplitude wants to merge 5 commits into
Conversation
05b0832 to
87f1a0d
Compare
| row.innerHTML = | ||
| `<span style="color:${color};font-weight:700">${kind}</span> ` + | ||
| `<span style="color:${ok ? '#7ee787' : '#ff7b72'}">${ok ? '✓ round-trips' : '✗ no round-trip'}</span>` + | ||
| (extra ? ` <span style="color:#8b949e">${extra}</span>` : '') + | ||
| `<span data-badge style="float:right;color:#8b949e"></span>` + | ||
| `<div style="margin-top:2px;color:#eee">${selector.replace(/</g, '<')}</div>`; |
There was a problem hiding this comment.
User controlled data in methods like innerHTML, outerHTML or document.write is an anti-pattern that can lead to XSS vulnerabilities
🧁 Removed in commit 87f1a0d 🧁
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stale shadow tests use old APIs
- Removed leftover shadow-capture.test.ts, which called helpers with pre-ShadowMode signatures and imported symbols helpers no longer export; the same cases are already covered against the current APIs.
Or push these changes by commenting:
@cursor push 608ed1b003
Preview (608ed1b003)
diff --git a/packages/plugin-autocapture-browser/test/shadow-capture.test.ts b/packages/plugin-autocapture-browser/test/shadow-capture.test.ts
deleted file mode 100644
--- a/packages/plugin-autocapture-browser/test/shadow-capture.test.ts
+++ /dev/null
@@ -1,395 +1,0 @@
-/**
- * Shadow-DOM capture-side behavior: ancestor walks, deep discovery helpers, the
- * composedPath event-target resolution, and the MutationObserver fan-out.
- *
- * jsdom supports `attachShadow({ mode: 'open' })`, `getRootNode`,
- * `composedPath`, and `MutationObserver`, so these exercise the real paths.
- */
-/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-return */
-import {
- getClosestElement,
- collectOpenShadowRoots,
- querySelectorAllDeep,
- MAX_SHADOW_TRAVERSAL_NODES,
-} from '../src/helpers';
-import { getAncestors } from '../src/hierarchy';
-import { createMutationObservable } from '../src/observables';
-import { DataExtractor } from '../src/data-extractor';
-
-function attachOpen(host: Element, html: string): ShadowRoot {
- const root = host.attachShadow({ mode: 'open' });
- root.innerHTML = html;
- return root;
-}
-
-/** Let jsdom's MutationObserver microtask flush. */
-const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
-
-afterEach(() => {
- document.body.innerHTML = '';
-});
-
-describe('getClosestElement — shadow crossing', () => {
- let host: Element;
- let inner: Element;
-
- beforeEach(() => {
- document.body.innerHTML = `<div id="app"><my-host></my-host></div>`;
- host = document.querySelector('my-host') as Element;
- const root = attachOpen(host, `<button id="cta">x</button>`);
- inner = root.getElementById('cta') as Element;
- });
-
- it('does NOT cross the shadow boundary by default (returns null)', () => {
- // The matching ancestor (#app) lives in the light DOM, outside the inner
- // element's shadow tree. Default behavior stops at the tree boundary.
- expect(getClosestElement(inner, ['#app'])).toBeNull();
- });
-
- it('crosses into the host and up to a light-DOM ancestor when enabled', () => {
- expect(getClosestElement(inner, ['#app'], true, 1)).toBe(document.getElementById('app'));
- });
-
- it('respects the crossing budget (depth 0 cannot cross)', () => {
- expect(getClosestElement(inner, ['#app'], true, 0)).toBeNull();
- });
-
- it('still matches within the same tree without crossing', () => {
- expect(getClosestElement(inner, ['#cta'], false, 0)).toBe(inner);
- });
-});
-
-describe('getAncestors — shadow crossing', () => {
- let inner: Element;
- let host: Element;
-
- beforeEach(() => {
- document.body.innerHTML = `<div id="app"><my-host></my-host></div>`;
- host = document.querySelector('my-host') as Element;
- const root = attachOpen(host, `<span id="inner">x</span>`);
- inner = root.getElementById('inner') as Element;
- });
-
- it('stops at the shadow boundary by default (only the element itself)', () => {
- // inner has no parentElement at the top of its shadow tree.
- expect(getAncestors(inner)).toEqual([inner]);
- });
-
- it('walks through the host into the light DOM when enabled', () => {
- const ancestors = getAncestors(inner, true, 1);
- expect(ancestors).toContain(inner);
- expect(ancestors).toContain(host);
- expect(ancestors).toContain(document.getElementById('app'));
- });
-});
-
-describe('collectOpenShadowRoots', () => {
- it('returns nothing when depth budget is 0', () => {
- document.body.innerHTML = `<my-host></my-host>`;
- attachOpen(document.querySelector('my-host') as Element, `<div></div>`);
- expect(collectOpenShadowRoots(document.body, 0)).toEqual([]);
- });
-
- it('finds nested open roots with their crossing depth, bounded by maxDepth', () => {
- document.body.innerHTML = `<my-card></my-card>`;
- const card = document.querySelector('my-card') as Element;
- const cardRoot = attachOpen(card, `<my-button></my-button>`);
- const button = cardRoot.querySelector('my-button') as Element;
- attachOpen(button, `<button>x</button>`);
-
- const depth1 = collectOpenShadowRoots(document.body, 1);
- expect(depth1).toHaveLength(1);
- expect(depth1[0].depth).toBe(1);
- expect(depth1[0].root).toBe(cardRoot);
-
- const depth2 = collectOpenShadowRoots(document.body, 2);
- expect(depth2.map((r) => r.depth).sort()).toEqual([1, 2]);
- });
-
- it('skips closed shadow roots (invisible from outside)', () => {
- document.body.innerHTML = `<my-host></my-host>`;
- (document.querySelector('my-host') as Element).attachShadow({ mode: 'closed' });
- expect(collectOpenShadowRoots(document.body, 5)).toEqual([]);
- });
-});
-
-describe('querySelectorAllDeep', () => {
- beforeEach(() => {
- document.body.innerHTML = `<button class="track">light</button><my-host></my-host>`;
- attachOpen(document.querySelector('my-host') as Element, `<button class="track">shadow</button>`);
- });
-
- it('returns only light-DOM matches when depth is 0', () => {
- const found = querySelectorAllDeep(document, '.track', 0);
- expect(found).toHaveLength(1);
- expect(found[0].textContent).toBe('light');
- });
-
- it('pierces open shadow roots when depth allows', () => {
- const found = querySelectorAllDeep(document, '.track', 1);
- expect(found.map((el) => el.textContent).sort()).toEqual(['light', 'shadow']);
- });
-});
-
-/**
- * Hardening / safety: none of the shadow capture-side helpers may throw into a
- * host page, even given a customer-supplied malformed selector, an exotic
- * (non-Element) event target, or a pathologically large DOM.
- */
-describe('shadow helpers — never throw on hostile input', () => {
- it('querySelectorAllDeep returns light-DOM matches even if a shadow root query is malformed', () => {
- // Valid selector against a normal tree still resolves; the guard only trips
- // per-root, so a valid selector keeps working.
- document.body.innerHTML = `<button class="track">x</button>`;
- expect(querySelectorAllDeep(document, '.track', 1).map((el) => el.textContent)).toEqual(['x']);
- });
-
- it('getClosestElement does not throw when the start node lacks getRootNode (e.g. window)', () => {
- const exotic = window as unknown as Element;
- expect(() => getClosestElement(exotic, ['button'], true, 2)).not.toThrow();
- expect(getClosestElement(exotic, ['button'], true, 2)).toBeNull();
- });
-
- it('querySelectorAllDeep returns [] for an empty selector string', () => {
- document.body.innerHTML = `<button class="track">x</button>`;
- expect(querySelectorAllDeep(document, '', 1)).toEqual([]);
- });
-
- it('querySelectorAllDeep defaults to light-DOM only (no depth arg) and accepts an Element root', () => {
- document.body.innerHTML = `<div id="scope"><button class="track">x</button><my-host></my-host></div>`;
- const scope = document.getElementById('scope') as Element;
- attachOpen(scope.querySelector('my-host') as Element, `<button class="track">y</button>`);
-
- // Default depth (arg omitted) → light DOM only.
- expect(querySelectorAllDeep(scope, '.track').map((el) => el.textContent)).toEqual(['x']);
- // Element (non-Document) root with depth → pierces the shadow root within scope.
- expect(
- querySelectorAllDeep(scope, '.track', 1)
- .map((el) => el.textContent)
- .sort(),
- ).toEqual(['x', 'y']);
- });
-
- it('collectOpenShadowRoots is bounded by MAX_SHADOW_TRAVERSAL_NODES', () => {
- expect(MAX_SHADOW_TRAVERSAL_NODES).toBeGreaterThan(0);
- // A wide-but-shallow tree: the traversal must terminate and never throw.
- const wide = document.createElement('div');
- for (let i = 0; i < 100; i++) {
- wide.appendChild(document.createElement('span'));
- }
- document.body.appendChild(wide);
- expect(() => collectOpenShadowRoots(document.body, 1)).not.toThrow();
- });
-});
-
-describe('DataExtractor.resolveEventTarget — composedPath', () => {
- let extractor: DataExtractor;
-
- beforeEach(() => {
- extractor = new DataExtractor({});
- });
-
- afterEach(() => {
- // Reset the shared engine config back to defaults so toggles don't leak.
- extractor.updateSelectorConfig();
- });
-
- it('returns event.target (retargeted host) when shadow support is off', () => {
- const host = document.createElement('my-host');
- const inner = document.createElement('button');
- const event = { target: host, composedPath: () => [inner, host] } as unknown as Event;
- expect(extractor.resolveEventTarget(event)).toBe(host);
- });
-
- it('returns the true inner element from composedPath when shadow support is on', () => {
- extractor.updateSelectorConfig({ shadowDomEnabled: true });
- const host = document.createElement('my-host');
- const inner = document.createElement('button');
- const event = { target: host, composedPath: () => [inner, host] } as unknown as Event;
- expect(extractor.resolveEventTarget(event)).toBe(inner);
- });
-
- it('falls back to event.target when composedPath is unavailable (shadow on)', () => {
- extractor.updateSelectorConfig({ shadowDomEnabled: true });
- const host = document.createElement('my-host');
- // No composedPath on the event (e.g. an environment/event that lacks it).
- const event = { target: host } as unknown as Event;
- expect(extractor.resolveEventTarget(event)).toBe(host);
- });
-});
-
-describe('createMutationObservable — shadow fan-out', () => {
- it('does NOT observe shadow-root mutations when disabled', async () => {
- document.body.innerHTML = `<my-host></my-host>`;
- const root = attachOpen(document.querySelector('my-host') as Element, `<div id="sr"></div>`);
-
- const batches: MutationRecord[][] = [];
- const sub = createMutationObservable(() => ({ enabled: false, maxDepth: 1 })).subscribe((m) => batches.push(m));
-
- // Mutate inside the shadow root — invisible to a body-only observer.
- root.getElementById('sr')?.appendChild(document.createElement('span'));
- await tick();
-
- const sawShadowMutation = batches.some((b) => b.some((rec) => rec.target.getRootNode() === root));
- expect(sawShadowMutation).toBe(false);
- sub.unsubscribe();
- });
-
- it('observes mutations inside an existing open shadow root when enabled', async () => {
- document.body.innerHTML = `<my-host></my-host>`;
- const root = attachOpen(document.querySelector('my-host') as Element, `<div id="sr"></div>`);
-
- const batches: MutationRecord[][] = [];
- const sub = createMutationObservable(() => ({ enabled: true, maxDepth: 1 })).subscribe((m) => batches.push(m));
-
- root.getElementById('sr')?.appendChild(document.createElement('span'));
- await tick();
-
- const sawShadowMutation = batches.some((b) => b.some((rec) => rec.target.getRootNode() === root));
- expect(sawShadowMutation).toBe(true);
- sub.unsubscribe();
- });
-
- it('attaches to a shadow root that mounts AFTER setup (late-loading component)', async () => {
- document.body.innerHTML = '';
- const batches: MutationRecord[][] = [];
- const sub = createMutationObservable(() => ({ enabled: true, maxDepth: 1 })).subscribe((m) => batches.push(m));
-
- // Mount a brand-new shadow host after the observable is already running.
- const host = document.createElement('my-host');
- document.body.appendChild(host);
- const root = attachOpen(host, `<div id="late"></div>`);
- await tick(); // body observer sees the host → attaches to its shadow root
-
- // Now mutate inside the newly-attached shadow root.
- root.getElementById('late')?.appendChild(document.createElement('span'));
- await tick();
-
- const sawShadowMutation = batches.some((b) => b.some((rec) => rec.target.getRootNode() === root));
- expect(sawShadowMutation).toBe(true);
- sub.unsubscribe();
- });
-
- it('observes mutations in a NESTED shadow root (depth 2) when the budget allows', async () => {
- document.body.innerHTML = `<my-card></my-card>`;
- const cardRoot = attachOpen(document.querySelector('my-card') as Element, `<my-button></my-button>`);
- const innerRoot = attachOpen(cardRoot.querySelector('my-button') as Element, `<div id="deep"></div>`);
-
- const batches: MutationRecord[][] = [];
- const sub = createMutationObservable(() => ({ enabled: true, maxDepth: 2 })).subscribe((m) => batches.push(m));
-
- innerRoot.getElementById('deep')?.appendChild(document.createElement('span'));
- await tick();
-
- const sawDeepMutation = batches.some((b) => b.some((rec) => rec.target.getRootNode() === innerRoot));
- expect(sawDeepMutation).toBe(true);
- sub.unsubscribe();
- });
-
- it('does NOT reach a nested (depth 2) shadow root when the budget is 1', async () => {
- document.body.innerHTML = `<my-card></my-card>`;
- const cardRoot = attachOpen(document.querySelector('my-card') as Element, `<my-button></my-button>`);
- const innerRoot = attachOpen(cardRoot.querySelector('my-button') as Element, `<div id="deep"></div>`);
-
- const batches: MutationRecord[][] = [];
- const sub = createMutationObservable(() => ({ enabled: true, maxDepth: 1 })).subscribe((m) => batches.push(m));
-
- innerRoot.getElementById('deep')?.appendChild(document.createElement('span'));
- await tick();
-
- const sawDeepMutation = batches.some((b) => b.some((rec) => rec.target.getRootNode() === innerRoot));
- expect(sawDeepMutation).toBe(false);
- sub.unsubscribe();
- });
-
- it('falls back to depth 0 when an observed shadow root is missing from the depth map', async () => {
- document.body.innerHTML = `<outer-host></outer-host>`;
- const outer = document.querySelector('outer-host') as Element;
- const outerRoot = attachOpen(outer, `<div id="slot"></div>`);
-
- const batches: MutationRecord[][] = [];
- const sub = createMutationObservable(() => ({ enabled: true, maxDepth: 1 })).subscribe((m) => batches.push(m));
-
- const originalGet = WeakMap.prototype.get;
- const spy = jest
- .spyOn(WeakMap.prototype, 'get')
- .mockImplementation(function (this: WeakMap<object, unknown>, key: object) {
- if (key === outerRoot) {
- return undefined;
- }
- return originalGet.call(this, key);
- });
-
- try {
- // Attach shadow before appending so the MO callback (which runs after this
- // synchronous turn) discovers the nested root via collectOpenShadowRoots.
- const nestedHost = document.createElement('nested-host');
- const nestedRoot = attachOpen(nestedHost, `<div id="inner"></div>`);
- outerRoot.getElementById('slot')?.appendChild(nestedHost);
- await tick();
-
- nestedRoot.getElementById('inner')?.appendChild(document.createElement('span'));
- await tick();
-
- const sawNested = batches.some((b) => b.some((rec) => rec.target.getRootNode() === nestedRoot));
- expect(sawNested).toBe(true);
- } finally {
- spy.mockRestore();
- sub.unsubscribe();
- }
- });
-
- it('with no shadow-config getter, behaves like a plain body observer (off path)', async () => {
- document.body.innerHTML = `<div id="light"></div>`;
- const batches: MutationRecord[][] = [];
- // No getter passed at all — the optional gate must default to disabled.
- const sub = createMutationObservable().subscribe((m) => batches.push(m));
-
- document.getElementById('light')?.appendChild(document.createElement('span'));
- await tick();
-
- expect(batches.length).toBeGreaterThan(0);
- sub.unsubscribe();
- });
-
- it('does not double-observe a shadow root already attached (re-scanned host)', async () => {
- document.body.innerHTML = `<my-host></my-host>`;
- const host = document.querySelector('my-host') as Element;
- const root = attachOpen(host, `<div id="sr"></div>`);
-
- const batches: MutationRecord[][] = [];
- const sub = createMutationObservable(() => ({ enabled: true, maxDepth: 1 })).subscribe((m) => batches.push(m));
-
- // Re-append the existing host: generates a childList mutation whose addedNode
- // is a host whose shadow root is ALREADY observed → observeRoot short-circuits.
- document.body.appendChild(host);
- await tick();
-
- // Mutating inside the (still singly-observed) shadow root emits exactly once.
- root.getElementById('sr')?.appendChild(document.createElement('span'));
- await tick();
-
- const shadowBatches = batches.filter((b) => b.some((rec) => rec.target.getRootNode() === root));
- // The span insertion is reported once, not duplicated by a second observer.
- const spanInsertions = shadowBatches
- .flat()
- .filter((rec) => Array.from(rec.addedNodes).some((n) => n.nodeName === 'SPAN'));
- expect(spanInsertions).toHaveLength(1);
- sub.unsubscribe();
- });
-
- it('still emits light-DOM mutations even if the shadow gate getter throws', async () => {
- document.body.innerHTML = `<div id="light"></div>`;
- const batches: MutationRecord[][] = [];
- // A hostile getter that throws must not break event flow (observer.next runs first).
- const sub = createMutationObservable(() => {
- throw new Error('boom');
- }).subscribe((m) => batches.push(m));
-
- document.getElementById('light')?.appendChild(document.createElement('span'));
- await tick();
-
- expect(batches.length).toBeGreaterThan(0);
- sub.unsubscribe();
- });
-});
\ No newline at end of fileYou can send follow-ups to the cloud agent here.
size-limit report 📦
|
87f1a0d to
ed17e3e
Compare
ed17e3e to
ae97c17
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Wrong shadow host detection
- getElementProperties now marks shadow hosts via element.shadowRoot, and the unit test uses attachShadow so it exercises the host rather than in-shadow nodes.
Or push these changes by commenting:
@cursor push bd93818677
Preview (bd93818677)
diff --git a/packages/plugin-autocapture-browser/src/hierarchy.ts b/packages/plugin-autocapture-browser/src/hierarchy.ts
--- a/packages/plugin-autocapture-browser/src/hierarchy.ts
+++ b/packages/plugin-autocapture-browser/src/hierarchy.ts
@@ -117,7 +117,7 @@
properties.attrs = attributes;
}
- if (isShadowRoot(element.getRootNode())) {
+ if (element.shadowRoot) {
properties.attrs = { ...(properties.attrs || {}), 'data-amp-internal-shadow': 'true' };
properties.shadow = true;
}
diff --git a/packages/plugin-autocapture-browser/test/hierarchy.test.ts b/packages/plugin-autocapture-browser/test/hierarchy.test.ts
--- a/packages/plugin-autocapture-browser/test/hierarchy.test.ts
+++ b/packages/plugin-autocapture-browser/test/hierarchy.test.ts
@@ -92,7 +92,7 @@
});
});
- test('should capture shadow attributes when element is in a shadow root', () => {
+ test('should capture shadow attributes when element is a shadow host', () => {
document.getElementsByTagName('body')[0].innerHTML = `
<div id="container">
<div id="inner">
@@ -100,8 +100,8 @@
</div>
</div>
`;
- const inner = document.getElementById('inner') as any;
- Object.defineProperty(inner, 'shadowRoot', { value: document.createElement('div') });
+ const inner = document.getElementById('inner') as HTMLElement;
+ inner.attachShadow({ mode: 'open' });
expect(HierarchyUtil.getElementProperties(inner, new Set())).toEqual({
id: 'inner',
index: 0,You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit ae97c17. Configure here.
… EPD-794-add-shadow-to-hierarchy
49db9f6 to
e8b0479
Compare


Summary
Mark shadow elements in Hierarchy Nodes.
Checklist
Note
Low Risk
Additive hierarchy metadata for shadow hosts with spoofing guarded; no auth or ingestion contract changes beyond optional event properties.
Overview
Autocapture element hierarchy now flags shadow DOM hosts so analysts can see where the composed tree crosses a shadow boundary.
When
getElementPropertiesruns on an element with an openshadowRoot, the hierarchy node gainsshadow: trueand a synthesizeddata-amp-internal-shadow: trueinattrs. That attribute is blocked from normal DOM capture and never echoed from page markup, so only SDK-detected hosts are marked. Nodes inside a shadow tree are unchanged.HierarchyNodetyping adds optionalshadow. Unit tests cover host marking, inner elements, and spoofed attributes. The shadow DOM test harness initializes Amplitude withimport.meta.env.VITE_AMPLITUDE_API_KEYinstead of a hardcoded key.Reviewed by Cursor Bugbot for commit e8b0479. Bugbot is set up for automated code reviews on this repo. Configure here.