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 00000000000..efa1ad8380c --- /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 00000000000..f6773a717b4 --- /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/index.ts b/packages/core/src/debug/ai/index.ts new file mode 100644 index 00000000000..50b6fa26df2 --- /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 00000000000..fd2252986b6 --- /dev/null +++ b/packages/core/src/debug/ai/registration.ts @@ -0,0 +1,37 @@ +/** + * @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 {diGraphTool} from './di_graph'; +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: [diGraphTool, signalGraphTool], + }); + } + + window.addEventListener('devtoolstooldiscovery', listener); + return () => { + window.removeEventListener('devtoolstooldiscovery', listener); + }; +} 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 00000000000..a033549c328 --- /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/src/debug/ai/tool_definitions.ts b/packages/core/src/debug/ai/tool_definitions.ts new file mode 100644 index 00000000000..e29f316e10f --- /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; +} diff --git a/packages/core/src/platform/platform.ts b/packages/core/src/platform/platform.ts index e6dd381ddb4..a77fb7af347 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/core/src/render3/util/view_traversal_utils.ts b/packages/core/src/render3/util/view_traversal_utils.ts index a2eb9670c80..40e5450f286 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/debug/ai/di_graph_spec.ts b/packages/core/test/debug/ai/di_graph_spec.ts new file mode 100644 index 00000000000..f502ea7c43b --- /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\?/, + ); + }), + ); +}); 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 00000000000..63432c34c7a --- /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(); + }); + }); +}); 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 00000000000..4b01de3017a --- /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/); + }); +}); 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 00000000000..51c8bcffe93 --- /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'); + }); + }); +}); diff --git a/packages/platform-browser/test/browser/bootstrap_spec.ts b/packages/platform-browser/test/browser/bootstrap_spec.ts index 3af1e1070c7..0f80f470536 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[] = [];