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);
}