diff --git a/packages/ng-devtools/src/__tests__/overlay-tree.test.ts b/packages/ng-devtools/src/__tests__/overlay-tree.test.ts new file mode 100644 index 0000000..3d55ff7 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/overlay-tree.test.ts @@ -0,0 +1,226 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { + AngularDebugApi, + ComponentTreeNode, + collectComponentTree, + walkAngularTree, +} from '../overlay.ts'; + +describe('overlay component tree traversal', () => { + it('traverses direct component children', () => { + document.body.innerHTML = ` + + + + + `; + + const componentsMap = new Map(); + const rootEl = document.querySelector('app-root')!; + const headerEl = document.querySelector('app-header')!; + const footerEl = document.querySelector('app-footer')!; + + componentsMap.set(rootEl, { name: 'Root' }); + componentsMap.set(headerEl, { name: 'Header' }); + componentsMap.set(footerEl, { name: 'Footer' }); + + const mockNg: AngularDebugApi = { + getComponent: (el: Element) => componentsMap.get(el) ?? null, + }; + + const nodes: ComponentTreeNode[] = []; + walkAngularTree(rootEl, nodes, mockNg); + + expect(nodes.length).toBe(1); + expect(nodes[0].selector).toBe('app-root'); + expect(nodes[0].children.map((c) => c.selector)).toEqual(['app-header', 'app-footer']); + }); + + it('traverses through non-component HTML wrapper elements (fix for #11)', () => { + document.body.innerHTML = ` + +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ `; + + const componentsMap = new Map(); + const rootEl = document.querySelector('app-root')!; + const navEl = document.querySelector('app-navbar')!; + const prodEl = document.querySelector('app-product-list')!; + const footEl = document.querySelector('app-footer')!; + + componentsMap.set(rootEl, { name: 'Root' }); + componentsMap.set(navEl, { name: 'Navbar' }); + componentsMap.set(prodEl, { name: 'ProductList' }); + componentsMap.set(footEl, { name: 'Footer' }); + + const mockNg: AngularDebugApi = { + getComponent: (el: Element) => componentsMap.get(el) ?? null, + }; + + const nodes: ComponentTreeNode[] = []; + walkAngularTree(rootEl, nodes, mockNg); + + expect(nodes.length).toBe(1); + expect(nodes[0].selector).toBe('app-root'); + expect(nodes[0].children.map((c) => c.selector)).toEqual([ + 'app-navbar', + 'app-product-list', + 'app-footer', + ]); + }); + + it('handles multi-level nested components separated by HTML tags', () => { + document.body.innerHTML = ` + +
+ +
+ +
+
+
+
+ `; + + const componentsMap = new Map(); + const rootEl = document.querySelector('app-root')!; + const cardEl = document.querySelector('app-card')!; + const itemEl = document.querySelector('app-card-item')!; + + componentsMap.set(rootEl, { name: 'Root' }); + componentsMap.set(cardEl, { name: 'Card' }); + componentsMap.set(itemEl, { name: 'CardItem' }); + + const mockNg: AngularDebugApi = { + getComponent: (el: Element) => componentsMap.get(el) ?? null, + }; + + const nodes: ComponentTreeNode[] = []; + walkAngularTree(rootEl, nodes, mockNg); + + expect(nodes.length).toBe(1); + expect(nodes[0].selector).toBe('app-root'); + expect(nodes[0].children.length).toBe(1); + expect(nodes[0].children[0].selector).toBe('app-card'); + expect(nodes[0].children[0].children.length).toBe(1); + expect(nodes[0].children[0].children[0].selector).toBe('app-card-item'); + }); + + it('collects component tree using global ng when available', () => { + document.body.innerHTML = ` + +
+ +
+
+ `; + + const componentsMap = new Map(); + const rootEl = document.querySelector('app-root')!; + const sidebarEl = document.querySelector('app-sidebar')!; + + componentsMap.set(rootEl, { name: 'Root' }); + componentsMap.set(sidebarEl, { name: 'Sidebar' }); + + (window as unknown as { ng?: AngularDebugApi }).ng = { + getComponent: (el: Element) => componentsMap.get(el) ?? null, + }; + + const nodes = collectComponentTree(); + expect(nodes.length).toBe(1); + expect(nodes[0].selector).toBe('app-root'); + expect(nodes[0].children.map((c) => c.selector)).toEqual(['app-sidebar']); + }); + + it('deduplicates nested roots and avoids adding nested roots twice', () => { + document.body.innerHTML = ` + +
+ +
+
+ `; + + const componentsMap = new Map(); + const rootEl = document.querySelector('app-root')!; + const childEl = document.querySelector('app-child')!; + + componentsMap.set(rootEl, { name: 'Root' }); + componentsMap.set(childEl, { name: 'Child' }); + + (window as unknown as { ng?: AngularDebugApi }).ng = { + getComponent: (el: Element) => componentsMap.get(el) ?? null, + }; + + const nodes = collectComponentTree(); + expect(nodes.length).toBe(1); + expect(nodes[0].selector).toBe('app-root'); + expect(nodes[0].children.length).toBe(1); + expect(nodes[0].children[0].selector).toBe('app-child'); + }); + + it('falls back to walking document.body when window.ng is unavailable and roots is empty', () => { + delete (window as unknown as { ng?: AngularDebugApi }).ng; + + document.body.innerHTML = ` + + + + `; + + const nodes = collectComponentTree(); + expect(nodes.length).toBe(1); + expect(nodes[0].selector).toBe('custom-widget'); + expect(nodes[0].children.length).toBe(1); + expect(nodes[0].children[0].selector).toBe('nested-item'); + }); + + it('serializes signal results and non-function plain fields in tryGetInputs', () => { + let arbitraryMethodCalled = false; + const signalFn = function signalValueFn() { + return { nested: 'value', count: 10 }; + }; + + const regularMethod = () => { + arbitraryMethodCalled = true; + return 'should-not-run'; + }; + + document.body.innerHTML = ``; + const widgetEl = document.querySelector('app-widget')!; + + const mockComp = { + signalInput: signalFn, + plainObject: { theme: 'dark', active: true }, + plainPrimitive: 'hello', + actionMethod: regularMethod, + }; + + (window as unknown as { ng?: AngularDebugApi }).ng = { + getComponent: (el: Element) => (el === widgetEl ? mockComp : null), + }; + + const nodes = collectComponentTree(); + expect(nodes.length).toBe(1); + expect(nodes[0].inputs).toEqual({ + signalInput: { nested: 'value', count: 10 }, + plainObject: { theme: 'dark', active: true }, + plainPrimitive: 'hello', + }); + expect(arbitraryMethodCalled).toBe(false); + }); +}); diff --git a/packages/ng-devtools/src/overlay.ts b/packages/ng-devtools/src/overlay.ts index adbe977..aed66da 100644 --- a/packages/ng-devtools/src/overlay.ts +++ b/packages/ng-devtools/src/overlay.ts @@ -63,25 +63,49 @@ export async function initOverlay(options: { baseURL?: string | string[] } = {}) }; } -function collectComponentTree() { +export interface AngularDebugApi { + getComponent(el: Element): unknown; + getInjector?(el: Element): unknown; + ɵgetSignalGraph?(injector: unknown): unknown; +} + +function findAngularElements(): Element[] { + const versionEls = Array.from(document.querySelectorAll('[ng-version]')); + const allEls = Array.from(document.querySelectorAll('*')); + const hostEls = allEls.filter((el) => + Array.from(el.attributes).some((a) => a.name.startsWith('_nghost')), + ); + return Array.from(new Set([...versionEls, ...hostEls])); +} + +export function collectComponentTree() { const nodes: ComponentTreeNode[] = []; - const roots = document.querySelectorAll('[ng-version], [_nghost-ng-c]'); + const allRoots = findAngularElements(); + const roots = allRoots.filter( + (root) => !allRoots.some((other) => other !== root && other.contains(root)), + ); // Use Angular's debug utilities if available - const ng = (window as any).ng; + const ng = (window as unknown as { ng?: AngularDebugApi }).ng; if (ng?.getComponent) { - for (const root of roots) { - walkAngularTree(root, nodes, ng); + if (roots.length > 0) { + for (const root of roots) { + walkAngularTree(root, nodes, ng); + } + } else if (typeof document !== 'undefined' && document.body) { + walkAngularTree(document.body, nodes, ng); } } else { // Fallback: walk DOM for Angular component host elements - walkDom(document.body, nodes); + if (typeof document !== 'undefined' && document.body) { + walkDom(document.body, nodes); + } } return nodes; } -interface ComponentTreeNode { +export interface ComponentTreeNode { id: string; selector: string; tagName: string; @@ -89,28 +113,34 @@ interface ComponentTreeNode { inputs?: Record; } -function walkAngularTree(el: Element, out: ComponentTreeNode[], ng: any) { +export function walkAngularTree(el: Element, out: ComponentTreeNode[], ng: AngularDebugApi) { const component = ng.getComponent(el); - if (!component) return; - - const node: ComponentTreeNode = { - id: generateId(el), - selector: el.tagName.toLowerCase(), - tagName: el.tagName.toLowerCase(), - children: [], - inputs: tryGetInputs(component), - }; - for (const child of el.querySelectorAll(':scope > *')) { - walkAngularTree(child, node.children, ng); - } + if (component) { + const node: ComponentTreeNode = { + id: generateId(el), + selector: el.tagName.toLowerCase(), + tagName: el.tagName.toLowerCase(), + children: [], + inputs: tryGetInputs(component), + }; - out.push(node); + for (const child of el.children) { + walkAngularTree(child, node.children, ng); + } + + out.push(node); + } else { + for (const child of el.children) { + walkAngularTree(child, out, ng); + } + } } function walkDom(el: Element, out: ComponentTreeNode[]) { const tagName = el.tagName.toLowerCase(); - const isComponent = tagName.includes('-') || el.hasAttribute('_nghost-ng-c'); + const isComponent = + tagName.includes('-') || Array.from(el.attributes).some((a) => a.name.startsWith('_nghost')); if (isComponent) { const node: ComponentTreeNode = { @@ -130,15 +160,28 @@ function walkDom(el: Element, out: ComponentTreeNode[]) { } } -function tryGetInputs(component: any): Record | undefined { +function isSignal(val: unknown): val is () => unknown { + if (typeof val !== 'function') return false; + if (val.name === 'signalValueFn') return true; + const symbols = Object.getOwnPropertySymbols(val); + return symbols.some((s) => s.description === 'SIGNAL' || s.toString().includes('SIGNAL')); +} + +function tryGetInputs(component: unknown): Record | undefined { + if (!component || typeof component !== 'object') return undefined; try { const inputs: Record = {}; - for (const key of Object.keys(component)) { - const val = component[key]; - if (typeof val === 'function' && val.name === 'signalValueFn') { - inputs[key] = val(); + const comp = component as Record; + for (const key of Object.keys(comp)) { + const val = comp[key]; + if (isSignal(val)) { + try { + inputs[key] = serializeValue(val()); + } catch { + // skip + } } else if (typeof val !== 'function') { - inputs[key] = val; + inputs[key] = serializeValue(val); } } return Object.keys(inputs).length > 0 ? inputs : undefined; @@ -532,8 +575,11 @@ function safeSerialize(val: unknown): unknown { } } -// Auto-init when loaded as a script -if (typeof document !== 'undefined') { +// Auto-init when loaded as a script (skip during test environment) +if ( + typeof document !== 'undefined' && + !(typeof process !== 'undefined' && process.env?.['VITEST']) +) { initOverlay().catch(console.error); import('./popup.ts').then((m) => m.createDevtoolsPopup()).catch(console.error); }