From 415854ef2f8c4618668058dd4b1a6a7cc6e746a1 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 09:24:58 -0700 Subject: [PATCH 1/6] refactor(core): define tool interfaces for AI agents These serve as the type definitions for interacting with the `chrome-devtools-mcp` AI runtime debugging functionality. Eventually this will hopefully be upstreamed to some more authoritative location, but for now this will do. --- .../core/src/debug/ai/tool_definitions.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/core/src/debug/ai/tool_definitions.ts diff --git a/packages/core/src/debug/ai/tool_definitions.ts b/packages/core/src/debug/ai/tool_definitions.ts new file mode 100644 index 000000000000..e29f316e10fc --- /dev/null +++ b/packages/core/src/debug/ai/tool_definitions.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Definition of an AI tool that can be exposed to Chrome DevTools. + */ +export interface ToolDefinition { + /** Name of the tool, should be namespaced (e.g., 'angular:di_graph'). */ + name: string; + + /** Human-readable description of what the tool does. */ + description: string; + + /** JSON schema for the tool's input arguments. */ + inputSchema: Record; + + /** Function that executes the tool. */ + execute: (args: T) => R | Promise; +} + +/** + * A group of related AI tools. + */ +export interface ToolGroup { + /** Name of the tool group. */ + name: string; + + /** List of tools in this group. */ + tools: ToolDefinition[]; +} + +/** + * Event dispatched by Chrome DevTools to discover tools in the page. + */ +export interface DevtoolsToolDiscoveryEvent extends CustomEvent { + /** Callback to register tools with DevTools. */ + respondWith(toolGroup: ToolGroup): void; +} From 9bf09027d8ba1c7eac7ffcddcbd7145997ef9c56 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 09:25:10 -0700 Subject: [PATCH 2/6] refactor(core): implement Angular signal graph tool This provides an `angular:signal_graph` in-page tool which exposes the signal graph from the component rendered for a particular DOM element. It leverages the algorithm defined for Angular DevTools, which essentially means it takes the effects registered on the components injector and walks transitive dependencies to find all signals referenced by the component in an effect or the template. --- packages/core/src/debug/ai/signal_graph.ts | 81 +++++++++++++++++++ .../core/test/debug/ai/signal_graph_spec.ts | 80 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/core/src/debug/ai/signal_graph.ts create mode 100644 packages/core/test/debug/ai/signal_graph_spec.ts diff --git a/packages/core/src/debug/ai/signal_graph.ts b/packages/core/src/debug/ai/signal_graph.ts new file mode 100644 index 000000000000..a033549c3289 --- /dev/null +++ b/packages/core/src/debug/ai/signal_graph.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {NullInjector} from '../../di/null_injector'; +import {getInjector} from '../../render3/util/discovery_utils'; +import {DebugSignalGraph, getSignalGraph} from '../../render3/util/signal_debug'; +import {ToolDefinition} from './tool_definitions'; + +// Omit `debuggableFn` and `id` from returned signal graph to AI agent. +type AiSignalGraph = Omit & { + nodes: Array>; +}; + +/** + * Tool that exposes Angular's signal dependency graph to AI agents. + */ +export const signalGraphTool: ToolDefinition<{target: HTMLElement}, AiSignalGraph> = { + name: 'angular:signal_graph', + // tslint:disable-next-line:no-toplevel-property-access + description: ` +Exposes the Angular signal dependency graph for a given DOM element. + +This tool extracts the reactive dependency graph (signals, computeds, and effects) that +are transitive dependencies of the effects of that element. It will include signals +authored in other components/services and depended upon by the target component, but +will *not* include signals only used in descendant components effects. + +Params: +- \`target\`: The element to get the signal graph for. Must be the host element of an + Angular component. + +Returns: +- \`nodes\`: An array of reactive nodes discovered in the context. Each node contains: + - \`kind\`: The type of reactive node ('signal', 'computed', 'effect', or 'template' + for component template effects). + - \`value\`: The current evaluated value of the node (if applicable). + - \`label\`: The symbol name of the associated signal if available (ex. + \`const foo = signal(0);\` has \`label: 'foo'\`). + - \`epoch\`: The internal version number of the node's value. +- \`edges\`: An array of dependency links representing which nodes read from which other + nodes. + - \`consumer\`: The index in the \`nodes\` array of the node that depends on the value. + - \`producer\`: The index in the \`nodes\` array of the node that provides the value. + +Example: An edge with \`{consumer: 2, producer: 0}\` means that \`nodes[2]\` (e.g. an +\`effect\`) reads the value of \`nodes[0]\` (e.g. a \`signal\`). + `.trim(), + inputSchema: { + type: 'object', + properties: { + target: { + type: 'object', + description: 'The element to get the signal graph for.', + 'x-mcp-type': 'HTMLElement', + }, + }, + required: ['target'], + }, + execute: async ({target}: {target: HTMLElement}) => { + if (!(target instanceof HTMLElement)) { + throw new Error('Invalid input: "target" must be an HTMLElement.'); + } + + const injector = getInjector(target); + if (injector instanceof NullInjector) { + throw new Error('Invalid input: "target" is not the host element of an Angular component.'); + } + + const graph = getSignalGraph(injector); + return { + // Filter out unneeded data. + nodes: graph.nodes.map(({id, debuggableFn, ...node}) => node), + edges: graph.edges, + }; + }, +}; diff --git a/packages/core/test/debug/ai/signal_graph_spec.ts b/packages/core/test/debug/ai/signal_graph_spec.ts new file mode 100644 index 000000000000..4b01de3017a2 --- /dev/null +++ b/packages/core/test/debug/ai/signal_graph_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component, computed, effect, signal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {signalGraphTool} from '../../../src/debug/ai/signal_graph'; +import {setupFrameworkInjectorProfiler} from '../../../src/render3/debug/framework_injector_profiler'; + +describe('signalGraphTool', () => { + beforeEach(() => { + setupFrameworkInjectorProfiler(); + }); + + it('should discover signal graph from targeted element', async () => { + @Component({ + selector: 'signal-graph-test-root', + template: '
Signals test: {{ double() }}
', + standalone: true, + }) + class SignalGraphTestComponent { + private readonly count = signal(1, {debugName: 'count'}); + protected readonly double = computed(() => this.count() * 2, {debugName: 'double'}); + + constructor() { + effect( + () => { + this.double(); + }, + {debugName: 'consumer'}, + ); + } + } + + const fixture = TestBed.createComponent(SignalGraphTestComponent); + await fixture.whenStable(); + + const rootElement = fixture.nativeElement; + + const result = await signalGraphTool.execute({target: rootElement}); + + expect(result.nodes).toEqual( + jasmine.arrayWithExactContents([ + jasmine.objectContaining({kind: 'signal', label: 'count', value: 1}), + jasmine.objectContaining({kind: 'computed', label: 'double', value: 2}), + jasmine.objectContaining({kind: 'effect', label: 'consumer'}), + jasmine.objectContaining({kind: 'template'}), + ]), + ); + + const countIndex = result.nodes.findIndex((n) => n.label === 'count'); + const doubleIndex = result.nodes.findIndex((n) => n.label === 'double'); + const consumerIndex = result.nodes.findIndex((n) => n.label === 'consumer'); + const templateIndex = result.nodes.findIndex((n) => n.kind === 'template'); + + expect(result.edges).toEqual( + jasmine.arrayWithExactContents([ + {consumer: consumerIndex, producer: doubleIndex}, + {consumer: doubleIndex, producer: countIndex}, + {consumer: templateIndex, producer: doubleIndex}, + ]), + ); + }); + + it('should throw an error if target is not an HTMLElement', async () => { + await expectAsync( + signalGraphTool.execute({target: {} as unknown as HTMLElement}), + ).toBeRejectedWithError(/must be an HTMLElement/); + }); + + it('should throw an error if target is not an Angular component', async () => { + await expectAsync( + signalGraphTool.execute({target: document.createElement('div')}), + ).toBeRejectedWithError(/not the host element of an Angular component/); + }); +}); From 059c706e7a9ff2cdb1b247b16f03f47616fde7c6 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 09:25:16 -0700 Subject: [PATCH 3/6] refactor(core): add `registerAiTools` function This will centrally manage all the AI tools supported by Angular out of the box. --- packages/core/src/debug/ai/index.ts | 9 +++++ packages/core/src/debug/ai/registration.ts | 36 +++++++++++++++++ .../core/test/debug/ai/registration_spec.ts | 39 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 packages/core/src/debug/ai/index.ts create mode 100644 packages/core/src/debug/ai/registration.ts create mode 100644 packages/core/test/debug/ai/registration_spec.ts diff --git a/packages/core/src/debug/ai/index.ts b/packages/core/src/debug/ai/index.ts new file mode 100644 index 000000000000..50b6fa26df24 --- /dev/null +++ b/packages/core/src/debug/ai/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {registerAiTools} from './registration'; diff --git a/packages/core/src/debug/ai/registration.ts b/packages/core/src/debug/ai/registration.ts new file mode 100644 index 000000000000..2314c636d749 --- /dev/null +++ b/packages/core/src/debug/ai/registration.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signalGraphTool} from './signal_graph'; +import {DevtoolsToolDiscoveryEvent} from './tool_definitions'; + +/** + * Registers Angular AI tools with Chrome DevTools. + * + * This function listens for the `devtoolstooldiscovery` event and responds with + * the available Angular-specific tools. + * + * @returns A callback function to unregister the tools. + */ +export function registerAiTools(): () => void { + // No-op in non-browser environments. + if (typeof window === 'undefined') return () => {}; + + function listener(inputEvent: Event): void { + const event = inputEvent as DevtoolsToolDiscoveryEvent; + event.respondWith({ + name: 'Angular', + tools: [signalGraphTool], + }); + } + + window.addEventListener('devtoolstooldiscovery', listener); + return () => { + window.removeEventListener('devtoolstooldiscovery', listener); + }; +} diff --git a/packages/core/test/debug/ai/registration_spec.ts b/packages/core/test/debug/ai/registration_spec.ts new file mode 100644 index 000000000000..63432c34c7a0 --- /dev/null +++ b/packages/core/test/debug/ai/registration_spec.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signalGraphTool} from '../../../src/debug/ai/signal_graph'; +import {registerAiTools} from '../../../src/debug/ai'; +import {DevtoolsToolDiscoveryEvent} from '../../../src/debug/ai/tool_definitions'; + +describe('registration', () => { + describe('registerAiTools', () => { + it('should register the tools', () => { + const unregister = registerAiTools(); + try { + // Verify Angular responds to the event. + const event = new CustomEvent('devtoolstooldiscovery') as DevtoolsToolDiscoveryEvent; + event.respondWith = jasmine.createSpy('respondWith'); + window.dispatchEvent(event); + expect(event.respondWith).toHaveBeenCalledOnceWith({ + name: 'Angular', + + // Just check one tool. + tools: jasmine.arrayContaining([signalGraphTool]), + }); + } finally { + unregister(); + } + + // After unregistering, Angular should not react to the event. + const event = new CustomEvent('devtoolstooldiscovery') as DevtoolsToolDiscoveryEvent; + event.respondWith = jasmine.createSpy('respondWith'); + window.dispatchEvent(event); + expect(event.respondWith).not.toHaveBeenCalled(); + }); + }); +}); From 52e2f79852b4839ae5bce445f7ef08adb2f5829f Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Wed, 1 Apr 2026 17:15:04 -0700 Subject: [PATCH 4/6] feat(core): register AI runtime debugging tools This registers AI runtime debugging tools during platform creation and unregisters them when the platform is destroyed. This roughly matches existing usage of global utils with respect to timing. It is limited to dev mode only because these tools are exclusively for debugging Angular's internals and not something production users would leverage. --- packages/core/src/platform/platform.ts | 13 +++++++++++++ .../test/browser/bootstrap_spec.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/core/src/platform/platform.ts b/packages/core/src/platform/platform.ts index e6dd381ddb4c..a77fb7af347f 100644 --- a/packages/core/src/platform/platform.ts +++ b/packages/core/src/platform/platform.ts @@ -24,8 +24,10 @@ import {RuntimeError, RuntimeErrorCode} from '../errors'; import {PlatformRef} from './platform_ref'; import {PLATFORM_DESTROY_LISTENERS} from './platform_destroy_listeners'; +import {registerAiTools} from '../debug/ai'; let _platformInjector: Injector | null = null; +let _unregisterAiTools: (() => void) | null = null; /** * Creates a platform. @@ -43,6 +45,9 @@ export function createPlatform(injector: Injector): PlatformRef { publishDefaultGlobalUtils(); publishSignalConfiguration(); + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + _unregisterAiTools = registerAiTools(); + } // During SSR, using this setting and using an injector from the global can cause the // injector to be used for a different requjest due to concurrency. @@ -151,6 +156,11 @@ export function getPlatform(): PlatformRef | null { * @publicApi */ export function destroyPlatform(): void { + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + _unregisterAiTools?.(); + _unregisterAiTools = null; + } + getPlatform()?.destroy(); } @@ -165,6 +175,9 @@ export function createOrReusePlatformInjector(providers: StaticProvider[] = []): if (_platformInjector) return _platformInjector; publishDefaultGlobalUtils(); + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + _unregisterAiTools = registerAiTools(); + } // Otherwise, setup a new platform injector and run platform initializers. const injector = createPlatformInjector(providers); diff --git a/packages/platform-browser/test/browser/bootstrap_spec.ts b/packages/platform-browser/test/browser/bootstrap_spec.ts index 3af1e1070c77..0f80f4705367 100644 --- a/packages/platform-browser/test/browser/bootstrap_spec.ts +++ b/packages/platform-browser/test/browser/bootstrap_spec.ts @@ -817,6 +817,20 @@ describe('bootstrap factory method', () => { ); }); + it('should register AI tools', async () => { + const evt = new Event('devtoolstooldiscovery') as any; + evt.respondWith = jasmine.createSpy<() => void>('respondWith'); + window.dispatchEvent(evt); + expect(evt.respondWith).not.toHaveBeenCalled(); + + await bootstrap(HelloRootCmp); + window.dispatchEvent(evt); + expect(evt.respondWith).toHaveBeenCalledOnceWith({ + name: 'Angular', + tools: jasmine.any(Array), + }); + }); + describe('change detection', () => { const log: string[] = []; From a7d04c97efec874696b3493c6009cd2f59323c7a Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Fri, 3 Apr 2026 11:51:37 -0700 Subject: [PATCH 5/6] refactor(core): add `walkLViewDirectives` This walks all transitive descendant directives via the `LView` structure of the given input. This is a generic utility, but useful for finding all components in a tree to look for their associated `Injector` objects. One known limitation is that this does not cover child components of i18n messages as that was more complicated than I wanted to get into right now. --- .../src/render3/util/view_traversal_utils.ts | 88 ++++++++++++++++- .../render3/util/view_traversal_utils_spec.ts | 99 +++++++++++++++++++ 2 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 packages/core/test/render3/util/view_traversal_utils_spec.ts diff --git a/packages/core/src/render3/util/view_traversal_utils.ts b/packages/core/src/render3/util/view_traversal_utils.ts index a2eb9670c80f..40e5450f2864 100644 --- a/packages/core/src/render3/util/view_traversal_utils.ts +++ b/packages/core/src/render3/util/view_traversal_utils.ts @@ -9,11 +9,12 @@ import {assertDefined} from '../../util/assert'; import {assertLView} from '../assert'; import {readPatchedLView} from '../context_discovery'; -import {LContainer} from '../interfaces/container'; +import {CONTAINER_HEADER_OFFSET, LContainer} from '../interfaces/container'; +import {TNode} from '../interfaces/node'; import {isLContainer, isLView, isRootView} from '../interfaces/type_checks'; -import {CHILD_HEAD, CONTEXT, LView, NEXT} from '../interfaces/view'; +import {CHILD_HEAD, CONTEXT, LView, NEXT, TVIEW} from '../interfaces/view'; -import {getLViewParent} from './view_utils'; +import {getComponentLViewByIndex, getLViewParent} from './view_utils'; /** * Retrieve the root view from any component or `LView` by walking the parent `LView` until @@ -65,3 +66,84 @@ function getNearestLContainer(viewOrContainer: LContainer | LView | null) { } return viewOrContainer as LContainer | null; } + +/** + * A generator that yields the logical children of a given TNode in its LView. + * + * @param tNode The parent TNode. + * @param lView The current LView. + * @returns A generator that yields [TNode, LView] pairs for each logical child. + */ +function* walkLViewChildren(tNode: TNode, lView: LView): IterableIterator<[TNode, LView]> { + // Visit child TNodes in the current view. + let child = tNode.child; + while (child) { + yield [child, lView]; + child = child.next; + } + + // If this is a component, visit its internal view. + if (tNode.componentOffset > -1) { + const componentLView = getComponentLViewByIndex(tNode.index, lView); + if (isLView(componentLView)) { + const componentTView = componentLView[TVIEW]; + const firstChild = componentTView.firstChild; + if (firstChild) yield [firstChild, componentLView]; + } + } + + // If this is a container (like `@if`), visit its embedded views. + const slot = lView[tNode.index]; + if (isLContainer(slot)) { + for (let i = CONTAINER_HEADER_OFFSET; i < slot.length; i++) { + const embeddedLView = slot[i] as LView; + const embeddedTView = embeddedLView[TVIEW]; + const firstChild = embeddedTView.firstChild; + if (firstChild) yield [firstChild, embeddedLView]; + } + } +} + +/** + * Recursively iterates through transitive descendants of an input view. + * + * @param lView The input LView. + * @returns A generator that yields [TNode, LView] pairs for all descendants. + */ +function* walkLViewDescendants(lView: LView): IterableIterator<[TNode, LView]> { + const tView = lView[TVIEW]; + let child = tView.firstChild; + while (child) { + yield* walkTNodeDescendants(child, lView); + child = child.next; + } +} + +/** + * Recursively iterates through the descendants of a TNode in the view tree, + * yielding the node itself and all its transitive descendants. + * + * @param tNode The starting TNode. + * @param lView The LView associated with the TNode. + * @returns A generator that yields [TNode, LView] pairs for the node and its descendants. + */ +function* walkTNodeDescendants(tNode: TNode, lView: LView): IterableIterator<[TNode, LView]> { + yield [tNode, lView]; + for (const [childTNode, childLView] of walkLViewChildren(tNode, lView)) { + yield* walkTNodeDescendants(childTNode, childLView); + } +} + +/** + * Iterates through transitive descendants of an input view and filters down to only components/directives. + * + * @param lView The input LView. + * @returns A generator that yields [TNode, LView] pairs for nodes with directives. + */ +export function* walkLViewDirectives(lView: LView): IterableIterator<[TNode, LView]> { + for (const [tNode, currentLView] of walkLViewDescendants(lView)) { + if (tNode.directiveEnd > tNode.directiveStart) { + yield [tNode, currentLView]; + } + } +} diff --git a/packages/core/test/render3/util/view_traversal_utils_spec.ts b/packages/core/test/render3/util/view_traversal_utils_spec.ts new file mode 100644 index 000000000000..51c8bcffe932 --- /dev/null +++ b/packages/core/test/render3/util/view_traversal_utils_spec.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component} from '../../../src/metadata/directives'; +import {TestBed} from '@angular/core/testing'; +import {walkLViewDirectives} from '../../../src/render3/util/view_traversal_utils'; +import {getLContext} from '../../../src/render3/context_discovery'; + +describe('view_traversal_utils', () => { + describe('walkLViewDirectives', () => { + it('should yield all components in the view hierarchy', async () => { + @Component({ + selector: 'child-comp', + template: 'Child', + }) + class ChildComponent {} + + @Component({ + selector: 'test-comp', + template: '
', + imports: [ChildComponent], + }) + class TestComponent {} + + const fixture = TestBed.createComponent(TestComponent); + await fixture.whenStable(); + + const lView = getLContext(fixture.nativeElement)!.lView!; + const directives = Array.from(walkLViewDirectives(lView)); + const values = directives.map(([node]) => node.value); + + expect(values).toContain('child-comp'); + expect(values).not.toContain('div'); + expect(values).not.toContain('b'); + }); + + it('should handle embedded views in a container', async () => { + @Component({ + selector: 'child-comp', + template: 'Child', + }) + class ChildComponent {} + + @Component({ + selector: 'test-comp', + template: '
@if (show) {}
', + imports: [ChildComponent], + }) + class TestComponent { + show = true; + } + + const fixture = TestBed.createComponent(TestComponent); + await fixture.whenStable(); + + const lView = getLContext(fixture.nativeElement)!.lView!; + const directives = Array.from(walkLViewDirectives(lView)); + const values = directives.map(([node]) => node.value); + + expect(values).toContain('child-comp'); + }); + + it('should handle content projection', async () => { + @Component({ + selector: 'projected-comp', + template: 'Projected', + }) + class ProjectedComponent {} + + @Component({ + selector: 'projector-comp', + template: '
', + }) + class ProjectorComponent {} + + @Component({ + selector: 'test-comp', + template: '', + imports: [ProjectorComponent, ProjectedComponent], + }) + class TestComponent {} + + const fixture = TestBed.createComponent(TestComponent); + await fixture.whenStable(); + + const lView = getLContext(fixture.nativeElement)!.lView!; + const directives = Array.from(walkLViewDirectives(lView)); + const values = directives.map(([node]) => node.value); + + expect(values).toContain('projector-comp'); + expect(values).toContain('projected-comp'); + }); + }); +}); From 0de20ff5d658984c723c25d5925ee3c96603cd5f Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 11:11:50 -0700 Subject: [PATCH 6/6] feat(core): implement Angular DI graph in-page AI tool This creates a new `angular:di-graph` in-page tool which returns the entire dependency injection graph for the application. We use the following rough algorithm for discovering all element injectors: 1. Find all root `LView` objects by querying for `[ng-version]`. 2. Walk all the transitive `LView` descendants of the roots. 3. Filter these `LView` objects to just directives. 4. Find the injector for a given directive and walk up its ancestors to find all element injectors. Discovering environment injectors works mostly the same way, just following the environment injector graph instead. This approach has a few known limitations which are out of scope for the moment: 1. Any given component typically has both an element injector *and* an environment injector. The relationship of "component -> environment injector" is not expressed in the result as of now, meaning the AI doesn't really have any insight into _which_ environment injector is being used for a particular component, though the injector will be one of the returned values. 2. The implementation does not support MFE use cases of multiple applications on the page at the same time. 3. The performance is not ideal, as we walk `LView` descendants twice and walk up the injector tree for every directive, repeatedly covering the same scope (ideally we'd just walk up every *leaf* directive, which would cover the same result for less effort). However for a debug tool, this is likely fine for now and we can optimize later if/when it becomes necessary. I did consider reusing more of the existing implementation in `global_utils` which exists to support Angular DevTools (we are already using some of it), however the existing support in `@angular/core` is actually fairly limited, returning very primitive data structures and relying on Angular DevTools to do the heavier lifting of collapsing the code into a usable graph representation. There's a potential path in the future to converge these implementations and potentially have `global_utils` use some of this code instead, but I will leave that for a future cleanup effort. --- packages/core/src/debug/ai/di_graph/index.ts | 260 ++++++++ .../debug/ai/di_graph/serialized_di_graph.ts | 133 ++++ packages/core/src/debug/ai/registration.ts | 3 +- packages/core/test/debug/ai/di_graph_spec.ts | 582 ++++++++++++++++++ 4 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/debug/ai/di_graph/index.ts create mode 100644 packages/core/src/debug/ai/di_graph/serialized_di_graph.ts create mode 100644 packages/core/test/debug/ai/di_graph_spec.ts diff --git a/packages/core/src/debug/ai/di_graph/index.ts b/packages/core/src/debug/ai/di_graph/index.ts new file mode 100644 index 000000000000..efa1ad8380c5 --- /dev/null +++ b/packages/core/src/debug/ai/di_graph/index.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {getLContext} from '../../../render3/context_discovery'; +import {NodeInjector} from '../../../render3/di'; +import {TDirectiveHostNode, TNode} from '../../../render3/interfaces/node'; +import {INJECTOR, LView, T_HOST, TVIEW, TViewType} from '../../../render3/interfaces/view'; +import {SerializedDiGraph, SerializedInjector, serializeInjector} from './serialized_di_graph'; +import {ChainedInjector} from '../../../render3/chained_injector'; +import {Injector} from '../../../di/injector'; +import {ToolDefinition} from '../tool_definitions'; +import {getLViewParent} from '../../../render3/util/view_utils'; +import {R3Injector} from '../../../di/r3_injector'; +import {NullInjector} from '../../../di/null_injector'; +import {walkLViewDirectives} from '../../../render3/util/view_traversal_utils'; + +/** Tool that exposes Angular's DI graph to AI agents. */ +export const diGraphTool: ToolDefinition<{}, SerializedDiGraph> = { + name: 'angular:di_graph', + // tslint:disable-next-line:no-toplevel-property-access + description: ` +Exposes the Angular Dependency Injection (DI) graph of the application. + +This tool extracts both the element injector tree (associated with DOM elements and components) +and the environment injector tree (associated with modules and standalone application roots). +It captures the relationship structure and the providers resolved at each level. + +Returns: +- \`elementInjectorRoots\`: An array of root element injectors (one for each Angular application + root found). Each node forms a tree hierarchy: + - \`name\`: The constructor name of the injector. + - \`type\`: 'element'. + - \`providers\`: Array of providers configured on that specific node. + - \`token\`: The DI token requested. + - \`value\`: The resolved value of that provider if it was instantiated. + - \`hostElement\`: The DOM element that this injector is associated with. + - \`children\`: Array of child element injectors. +- \`environmentInjectorRoot\`: The root environment injector. It forms a tree hierarchy of nodes + representing all environment injectors: + - \`name\`: The identifier for the environment injector. + - \`type\`: 'environment' or 'null'. + - \`providers\`: Array of providers configured on that injector. + - \`children\`: Array of child environment injectors. + `.trim(), + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async () => { + const roots = Array.from(document.querySelectorAll('[ng-version]')) as HTMLElement[]; + if (roots.length === 0) { + throw new Error('Could not find Angular root element ([ng-version]) on the page.'); + } + return discoverDiGraph(roots); + }, +}; + +/** + * Traverses the Angular internal tree from the root to discover element and environment injectors. + */ +function discoverDiGraph(roots: HTMLElement[]): SerializedDiGraph { + const rootLViews = roots.map((root) => { + const lContext = getLContext(root); + if (!lContext?.lView) { + throw new Error( + `Could not find an \`LView\` for root \`<${root.tagName.toLowerCase()}>\`, is it an Angular component?`, + ); + } + return lContext.lView; + }); + + return { + elementInjectorRoots: rootLViews.map((rootLView) => walkElementInjectors(rootLView)), + environmentInjectorRoot: collectEnvInjectors(rootLViews), + }; +} + +/** + * Traverses all directive-hosting nodes in the `rootLView` hierarchy and builds a tree of + * serialized element injectors. + * + * This function uses `walkLViewDirectives` to visit nodes in depth-first order and a stack + * to reconstruct the hierarchical tree of injectors, handling both same-view and cross-view + * relationships. + * + * @param rootLView The root view to start traversal from. + * @returns The root {@link SerializedInjector} object. + */ +function walkElementInjectors(rootLView: LView): SerializedInjector { + // Assert that we were given a root `LView` rather than a random component. + // A root component actually gets two `LView` objects, the "root `LView`" with + // `type === TViewType.Root` and then an `LView` for the component itself as a child. + if (rootLView[TVIEW].type !== TViewType.Root) { + throw new Error(`Expected a root LView but got type: \`${rootLView[TVIEW].type}\`.`); + } + + // Track the injectors we're currently processing. + const stack: Array<[TNode, LView, SerializedInjector]> = []; + + // By constraining `rootLView` to only accepting root `LView` objects, we don't have to + // process `rootLView` itself, knowing that it won't be a component or directive. + // We can just check its descendants. + for (const [tNode, lView] of walkLViewDirectives(rootLView)) { + const injector = new NodeInjector(tNode as TDirectiveHostNode, lView); + const serialized = serializeInjector(injector); + + // Look for our nearest ancestor in the stack. + while (stack.length > 0) { + const [lastTNode, lastLView, lastInjector] = stack[stack.length - 1]; + + const isDescendantInSameView = isTNodeDescendant(tNode, lastTNode); + const isDescendantInDifferentView = isLViewDescendantOfTNode(lView, lastLView, lastTNode); + if (isDescendantInSameView || isDescendantInDifferentView) { + // This injector is a child of the current last injector in the stack. + lastInjector.children.push(serialized); + break; + } else { + stack.pop(); + } + } + + // Future injectors might be children of this one. + stack.push([tNode, lView, serialized]); + } + + // Since all component/directive LViews are descendants of the root LView, the first + // item on the stack must still remain and will be the root injector. + if (stack.length === 0) { + throw new Error(`Expected at least one component/directive in the root \`LView\`.`); + } + const [, , rootInjector] = stack[0]; + return rootInjector; +} + +/** + * Collects and serializes all environment injectors found in the hierarchy of the given + * `rootLViews`. + * + * Injectors have pointers to their parents, but not their children, so walking "down" the + * hierarchy is not a generally supported operation. + * + * The function walks down the `LView` hierarchy to find all the component/directive descendants. + * For each one, it then walks back up the injector hierarchy to find the full set of environment + * injectors. + * + * @param rootLViews The root views to start traversal from. + * @returns The root {@link SerializedInjector} object containing the entire environment + * injector tree. + */ +function collectEnvInjectors(rootLViews: LView[]): SerializedInjector { + const serializedEnvInjectorMap = new Map(); + let rootEnvInjector: SerializedInjector | undefined = undefined; + + /** + * Serialize all the ancestors of the given injector and return + * its serialized version. + * + * @param injector The environment injector to start from. + * @returns The serialized form of the input {@link Injector}. + */ + function serializeAncestors(injector: Injector): SerializedInjector { + const existing = serializedEnvInjectorMap.get(injector); + if (existing) return existing; + + const serialized = serializeInjector(injector); + serializedEnvInjectorMap.set(injector, serialized); + + const parentInjector = getParentEnvInjector(injector); + if (parentInjector) { + // Recursively process the parent and attach ourselves as a child. + const parentSerialized = serializeAncestors(parentInjector); + parentSerialized.children.push(serialized); + } else { + // If there is no parent, this is a root environment injector. + if (!rootEnvInjector) { + rootEnvInjector = serialized; + } else if (rootEnvInjector !== serialized) { + throw new Error('Expected only one root environment injector, but found multiple.', { + cause: {firstRoot: rootEnvInjector, secondRoot: serialized}, + }); + } + } + + return serialized; + } + + // Process all descendant environment injectors. + for (const rootLView of rootLViews) { + for (const [, lView] of walkLViewDirectives(rootLView)) { + serializeAncestors(lView[INJECTOR]); + } + } + + if (!rootEnvInjector) { + throw new Error('Expected a root environment injector but did not find one.'); + } + + return rootEnvInjector; +} + +/** + * Checks if `node` is a descendant of `ancestor` within the SAME view. + * + * Since we are in the same view, we can safely use `tNode.parent` to determine + * if `ancestor` is an ancestor of the current `node`. + */ +function isTNodeDescendant(node: TNode, ancestor: TNode): boolean { + let curr: TNode | null = node; + while (curr) { + if (curr === ancestor) return true; + curr = curr.parent; + } + return false; +} + +/** + * Checks if `lView` is a descendant of `parentTNode` in `parentLView` (crossing view boundaries). + * + * `tNode.parent` is restricted to referring to nodes within the SAME view. When we cross + * view boundaries (e.g., entering a component's internal view or an embedded view like `@if`), + * `tNode.parent` becomes `null` or points to something inside that view, breaking the chain to the + * outside. + * + * To solve this, we use the `LView` hierarchy to find if the current view is a descendant of the + * `parentLView`. + */ +function isLViewDescendantOfTNode(lView: LView, parentLView: LView, parentTNode: TNode): boolean { + let currentLView: LView | null = lView; + let hostTNode: TNode | null = null; + + while (currentLView && currentLView !== parentLView) { + hostTNode = currentLView[T_HOST]; + currentLView = getLViewParent(currentLView); + } + + return ( + currentLView === parentLView && hostTNode !== null && isTNodeDescendant(hostTNode, parentTNode) + ); +} + +/** Find the parent environment injector of the given injector. */ +function getParentEnvInjector(injector: Injector): Injector | undefined { + if (injector instanceof ChainedInjector) { + // We skip `chainedInjector.injector` because that points at the parent element injector + // which is handled by `walkElementInjectors`. + const chainedInjector = injector; + return chainedInjector.parentInjector; + } else if (injector instanceof R3Injector) { + return injector.parent; + } else if (injector instanceof NullInjector) { + return undefined; + } else { + throw new Error(`Unknown injector type: "${injector.constructor.name}".`); + } +} diff --git a/packages/core/src/debug/ai/di_graph/serialized_di_graph.ts b/packages/core/src/debug/ai/di_graph/serialized_di_graph.ts new file mode 100644 index 000000000000..f6773a717b47 --- /dev/null +++ b/packages/core/src/debug/ai/di_graph/serialized_di_graph.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Injector} from '../../../di/injector'; +import {getNodeInjectorTNode} from '../../../render3/di'; +import {TNodeProviderIndexes} from '../../../render3/interfaces/node'; +import { + getInjectorMetadata, + getInjectorProviders, +} from '../../../render3/util/injector_discovery_utils'; + +/** + * A serialized representation of an Angular dependency injection graph. + */ +export interface SerializedDiGraph { + /** The roots of the element injector trees starting from the requested root elements. */ + elementInjectorRoots: SerializedInjector[]; + + /** The root of the environment injector tree for the application. */ + environmentInjectorRoot: SerializedInjector; +} + +/** + * A serialized representation of an Angular injector. + */ +export type SerializedInjector = + | ElementSerializedInjector + | EnvironmentSerializedInjector + | NullSerializedInjector; + +export interface ElementSerializedInjector { + name: string; + type: 'element'; + providers: SerializedProvider[]; + viewProviders: SerializedProvider[]; + children: SerializedInjector[]; + /** The host element associated with this injector. */ + hostElement: HTMLElement; +} + +export interface EnvironmentSerializedInjector { + name: string; + type: 'environment'; + providers: SerializedProvider[]; + children: SerializedInjector[]; +} + +export interface NullSerializedInjector { + name: string; + type: 'null'; + providers: SerializedProvider[]; + children: SerializedInjector[]; +} + +/** + * A serialized representation of a DI provider. + */ +export interface SerializedProvider { + token: any; + value: unknown; +} + +/** + * Gets a human-readable name for a DI token. + */ +export function getTokenName(token: any): string { + if (typeof token === 'function') { + return token.name || 'anonymous function'; + } + try { + return String(token); + } catch { + return '[Object]'; + } +} + +/** + * Serializes an injector and its children/providers into a tree. + */ +export function serializeInjector(injector: Injector): SerializedInjector { + const metadata = getInjectorMetadata(injector); + + if (metadata?.type === 'null') { + return { + name: 'Null Injector', + type: 'null', + providers: [], + children: [], + }; + } + + // Only attempt to get providers for types supported by getInjectorProviders. + let allProviders: SerializedProvider[] = []; + if (metadata?.type === 'element' || metadata?.type === 'environment') { + allProviders = getInjectorProviders(injector).map((record) => { + return { + token: record.token, + value: injector.get(record.token, null, {optional: true, self: true}), + }; + }); + } + + if (metadata?.type === 'element') { + const tNode = getNodeInjectorTNode(injector as any); + const viewProvidersCount = tNode + ? tNode.providerIndexes >> TNodeProviderIndexes.CptViewProvidersCountShift + : 0; + + const viewProviders = allProviders.slice(0, viewProvidersCount); + const resolvedProviders = allProviders.slice(viewProvidersCount); + + return { + name: injector.constructor.name, + type: 'element', + providers: resolvedProviders, + viewProviders, + children: [], + hostElement: metadata.source as HTMLElement, + }; + } + + return { + name: (metadata?.source as string) ?? injector.constructor.name ?? 'Unknown Injector', + type: 'environment', // Fallback for other injector types + providers: allProviders, + children: [], + }; +} diff --git a/packages/core/src/debug/ai/registration.ts b/packages/core/src/debug/ai/registration.ts index 2314c636d749..fd2252986b61 100644 --- a/packages/core/src/debug/ai/registration.ts +++ b/packages/core/src/debug/ai/registration.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import {diGraphTool} from './di_graph'; import {signalGraphTool} from './signal_graph'; import {DevtoolsToolDiscoveryEvent} from './tool_definitions'; @@ -25,7 +26,7 @@ export function registerAiTools(): () => void { const event = inputEvent as DevtoolsToolDiscoveryEvent; event.respondWith({ name: 'Angular', - tools: [signalGraphTool], + tools: [diGraphTool, signalGraphTool], }); } diff --git a/packages/core/test/debug/ai/di_graph_spec.ts b/packages/core/test/debug/ai/di_graph_spec.ts new file mode 100644 index 000000000000..f502ea7c43bf --- /dev/null +++ b/packages/core/test/debug/ai/di_graph_spec.ts @@ -0,0 +1,582 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + Component, + createComponent, + createEnvironmentInjector, + destroyPlatform, + Directive, + Injectable, + InjectionToken, +} from '@angular/core'; +import {bootstrapApplication, createApplication} from '@angular/platform-browser'; +import {withBody} from '@angular/private/testing'; +import {diGraphTool} from '../../../src/debug/ai/di_graph'; +import {setupFrameworkInjectorProfiler} from '../../../src/render3/debug/framework_injector_profiler'; + +describe('diGraphTool', () => { + beforeEach(() => { + destroyPlatform(); + setupFrameworkInjectorProfiler(); + }); + afterEach(() => void destroyPlatform()); + + it( + 'should discover element injectors', + withBody('', async () => { + @Injectable() + class CustomService {} + + @Component({ + selector: 'test-cmp', + template: '
Root
', + providers: [CustomService], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + type: 'element', + providers: [ + { + token: CustomService, + value: jasmine.any(CustomService), + }, + ], + viewProviders: [], + children: [], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should build hierarchical tree of element injectors with root component', + withBody('', async () => { + @Injectable() + class DirectiveService {} + + @Directive({ + selector: '[custom-dir]', + providers: [DirectiveService], + }) + class CustomDir {} + + @Injectable() + class ComponentService {} + + @Component({ + selector: 'test-cmp', + template: '
Root
', + imports: [CustomDir], + providers: [ComponentService], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + type: 'element', + providers: [ + { + token: ComponentService, + value: jasmine.any(ComponentService), + }, + ], + children: [ + jasmine.objectContaining({ + type: 'element', + providers: [ + { + token: DirectiveService, + value: jasmine.any(DirectiveService), + }, + ], + children: [], + }), + ], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should discover ancestor environment injectors', + withBody('', async () => { + @Injectable() + class EnvService {} + + @Component({ + selector: 'test-cmp', + template: '
Root
', + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp, { + providers: [EnvService], + }); + try { + const result = await diGraphTool.execute({}); + + expect(result.environmentInjectorRoot).toEqual( + jasmine.objectContaining({ + name: 'Null Injector', + children: jasmine.arrayContaining([ + jasmine.objectContaining({ + name: 'R3Injector', // Platform + children: jasmine.arrayContaining([ + jasmine.objectContaining({ + name: 'Environment Injector', // Root + providers: jasmine.arrayContaining([ + {token: EnvService, value: jasmine.any(EnvService)}, + ]), + }), + ]), + }), + ]), + }), + ); + } finally { + appRef.destroy(); + } + }), + ); + it( + 'should discover element injectors from multiple roots', + withBody('', async () => { + @Injectable() + class Service1 {} + + @Component({ + selector: 'app-root-1', + template: '
Root 1
', + providers: [Service1], + }) + class AppRoot1 {} + + @Injectable() + class Service2 {} + + @Component({ + selector: 'app-root-2', + template: '
Root 2
', + providers: [Service2], + }) + class AppRoot2 {} + + const appRef = await createApplication(); + appRef.bootstrap(AppRoot1); + appRef.bootstrap(AppRoot2); + + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + providers: [{token: Service1, value: jasmine.any(Service1)}], + }), + jasmine.objectContaining({ + providers: [{token: Service2, value: jasmine.any(Service2)}], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should handle content projection', + withBody('', async () => { + @Injectable() + class ProjectorService {} + + @Component({ + selector: 'projector', + template: '', + providers: [ProjectorService], + }) + class ProjectorCmp {} + + @Injectable() + class ChildService {} + + @Directive({ + selector: '[child-dir]', + providers: [ChildService], + }) + class ChildDir {} + + @Injectable() + class TestService {} + + @Component({ + selector: 'test-cmp', + template: '
', + imports: [ProjectorCmp, ChildDir], + providers: [TestService], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + providers: jasmine.arrayWithExactContents([ + { + token: TestService, + value: jasmine.any(TestService), + }, + ]), + children: [ + jasmine.objectContaining({ + providers: jasmine.arrayWithExactContents([ + { + token: ProjectorService, + value: jasmine.any(ProjectorService), + }, + ]), + children: [ + jasmine.objectContaining({ + type: 'element', + providers: jasmine.arrayWithExactContents([ + { + token: ChildService, + value: jasmine.any(ChildService), + }, + ]), + }), + ], + }), + ], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should handle embedded views (`@if`)', + withBody('', async () => { + @Injectable() + class DirectiveService {} + + @Directive({ + selector: '[child-dir]', + providers: [DirectiveService], + }) + class ChildDir {} + + @Injectable() + class ComponentService {} + + @Component({ + selector: 'test-cmp', + template: '@if (true) {
}', + imports: [ChildDir], + providers: [ComponentService], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + providers: [ + { + token: ComponentService, + value: jasmine.any(ComponentService), + }, + ], + children: [ + jasmine.objectContaining({ + type: 'element', + providers: [ + { + token: DirectiveService, + value: jasmine.any(DirectiveService), + }, + ], + }), + ], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should handle host directives', + withBody('', async () => { + @Injectable() + class HostService {} + + @Directive({ + selector: '[host-dir]', + providers: [HostService], + }) + class HostDir {} + + @Injectable() + class TestService {} + + @Component({ + selector: 'test-cmp', + template: '
Root
', + hostDirectives: [HostDir], + providers: [TestService], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + // Components and host directives share the same `Injector`. + jasmine.objectContaining({ + providers: jasmine.arrayWithExactContents([ + { + token: HostService, + value: jasmine.any(HostService), + }, + { + token: TestService, + value: jasmine.any(TestService), + }, + ]), + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should handle view providers', + withBody('', async () => { + @Injectable() + class TestService {} + + @Injectable() + class ViewService {} + + @Component({ + selector: 'test-cmp', + template: '
Root
', + providers: [TestService], + viewProviders: [ViewService], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + providers: [ + { + token: TestService, + value: jasmine.any(TestService), + }, + ], + viewProviders: [ + { + token: ViewService, + value: jasmine.any(ViewService), + }, + ], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should handle dynamically created root components', + withBody('
', async () => { + @Injectable() + class DynamicService {} + + @Component({ + selector: 'dynamic-cmp', + template: '
Dynamic
', + providers: [DynamicService], + }) + class DynamicCmp {} + + @Injectable() + class TestService {} + + @Component({ + selector: 'test-cmp', + template: '
Root
', + providers: [TestService], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const hostEl = document.getElementById('dynamic-host')!; + + // Use createComponent with hostElement! + createComponent(DynamicCmp, { + environmentInjector: appRef.injector, + hostElement: hostEl, + }); + + const result = await diGraphTool.execute({}); + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + providers: [{token: TestService, value: jasmine.any(TestService)}], + }), + jasmine.objectContaining({ + providers: [{token: DynamicService, value: jasmine.any(DynamicService)}], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + it( + 'should handle custom environment injectors with `createComponent`', + withBody('
', async () => { + @Component({ + selector: 'dynamic-cmp', + template: '
Dynamic
', + }) + class DynamicCmp {} + + @Component({ + selector: 'test-cmp', + template: '
Root
', + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const hostEl = document.getElementById('dynamic-host')!; + + // Create custom environment injector! + const customEnv = createEnvironmentInjector([], appRef.injector); + + // NOTE: We cannot assert on the providers array here because manually created + // environment injectors bypass the profiler hooks that populate the providers + // manifest. However, we CAN assert that the injector itself was found and + // mapped into the hierarchy by searching the tree for its name. + const CustomConstructor = function () {}; + Object.defineProperty(CustomConstructor, 'name', {value: 'CustomEnvInjector'}); + (customEnv as any).constructor = CustomConstructor; + + // Use createComponent with custom environment injector! + createComponent(DynamicCmp, { + environmentInjector: customEnv, + hostElement: hostEl, + }); + + const result = await diGraphTool.execute({}); + + expect(result.environmentInjectorRoot).toEqual( + jasmine.objectContaining({ + name: 'Null Injector', + children: [ + jasmine.objectContaining({ + name: 'R3Injector', // Platform + children: [ + jasmine.objectContaining({ + name: 'Environment Injector', // Root + children: jasmine.arrayContaining([ + jasmine.objectContaining({ + name: 'CustomEnvInjector', + type: 'environment', + }), + ]), + }), + ], + }), + ], + }), + ); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should handle multi-providers', + withBody('', async () => { + const multiToken = new InjectionToken('MULTI_TOKEN'); + + @Component({ + selector: 'test-cmp', + template: '
Root
', + providers: [ + {provide: multiToken, useValue: 'val1', multi: true}, + {provide: multiToken, useValue: 'val2', multi: true}, + ], + }) + class TestCmp {} + + const appRef = await bootstrapApplication(TestCmp); + try { + const result = await diGraphTool.execute({}); + + expect(result.elementInjectorRoots).toEqual([ + jasmine.objectContaining({ + // There should only be one `multiToken`, but the framework profiler currently records + // multi-providers once per configuration rather than aggregating them by token. This + // causes them to appear multiple times in the providers array. + providers: [ + {token: multiToken, value: ['val1', 'val2']}, + {token: multiToken, value: ['val1', 'val2']}, + ], + }), + ]); + } finally { + appRef.destroy(); + } + }), + ); + + it( + 'should throw an error if a root is not an Angular component', + withBody('
', async () => { + await expectAsync(diGraphTool.execute({})).toBeRejectedWithError( + /Could not find an `LView` for root `
`, is it an Angular component\?/, + ); + }), + ); +});