From 702958e9684a21f3e079a21ff7e796fe2906dde0 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Wed, 5 Aug 2020 19:16:20 -0700 Subject: [PATCH] refactor(core): add debug ranges to `LViewDebug` with matchers (#38359) This change provides better typing for the `LView.debug` property which is intended to be used by humans while debugging the application with `ngDevMode` turned on. In addition this chang also adds jasmine matchers for better asserting that `LView` is in the correct state. PR Close #38359 --- goldens/circular-deps/packages.json | 51 ++-- .../size-tracking/integration-payloads.json | 2 +- .../src/render3/instructions/lview_debug.ts | 93 +++++--- .../core/src/render3/instructions/shared.ts | 4 +- packages/core/src/render3/interfaces/node.ts | 17 +- packages/core/src/render3/interfaces/view.ts | 200 +++++++++++++++- .../core/src/render3/util/discovery_utils.ts | 4 +- packages/core/test/acceptance/BUILD.bazel | 1 + packages/core/test/acceptance/debug_spec.ts | 63 ++++- packages/core/test/render3/BUILD.bazel | 15 ++ .../core/test/render3/interfaces/node_spec.ts | 22 ++ packages/core/test/render3/is_shape_of.ts | 186 +++++++++++++++ .../core/test/render3/is_shape_of_spec.ts | 37 +++ packages/core/test/render3/matchers.ts | 218 ++++++++++++++++++ packages/core/test/render3/matchers_spec.ts | 101 ++++++++ 15 files changed, 937 insertions(+), 77 deletions(-) create mode 100644 packages/core/test/render3/interfaces/node_spec.ts create mode 100644 packages/core/test/render3/is_shape_of.ts create mode 100644 packages/core/test/render3/is_shape_of_spec.ts create mode 100644 packages/core/test/render3/matchers.ts create mode 100644 packages/core/test/render3/matchers_spec.ts diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index c34ed8bc11..e8aebc9230 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -223,8 +223,23 @@ "packages/core/src/render3/assert.ts", "packages/core/src/render3/interfaces/container.ts", "packages/core/src/render3/interfaces/node.ts", - "packages/core/src/render3/interfaces/definition.ts", - "packages/core/src/core.ts", + "packages/core/src/render3/interfaces/view.ts", + "packages/core/src/di/injector.ts", + "packages/core/src/di/r3_injector.ts", + "packages/core/src/render3/definition.ts", + "packages/core/src/metadata/ng_module.ts" + ], + [ + "packages/core/src/application_ref.ts", + "packages/core/src/application_tokens.ts", + "packages/core/src/linker/component_factory.ts", + "packages/core/src/change_detection/change_detection.ts", + "packages/core/src/change_detection/change_detector_ref.ts", + "packages/core/src/render3/view_engine_compatibility.ts", + "packages/core/src/render3/assert.ts", + "packages/core/src/render3/interfaces/container.ts", + "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/view.ts", "packages/core/src/metadata.ts", "packages/core/src/di.ts", "packages/core/src/di/index.ts", @@ -247,25 +262,9 @@ "packages/core/src/render3/assert.ts", "packages/core/src/render3/interfaces/container.ts", "packages/core/src/render3/interfaces/node.ts", - "packages/core/src/render3/interfaces/definition.ts", "packages/core/src/render3/interfaces/view.ts", - "packages/core/src/di/injector.ts", - "packages/core/src/di/r3_injector.ts", - "packages/core/src/render3/definition.ts", - "packages/core/src/metadata/ng_module.ts" - ], - [ - "packages/core/src/application_ref.ts", - "packages/core/src/application_tokens.ts", - "packages/core/src/linker/component_factory.ts", - "packages/core/src/change_detection/change_detection.ts", - "packages/core/src/change_detection/change_detector_ref.ts", - "packages/core/src/render3/view_engine_compatibility.ts", - "packages/core/src/render3/assert.ts", - "packages/core/src/render3/interfaces/container.ts", - "packages/core/src/render3/interfaces/node.ts", "packages/core/src/render3/interfaces/definition.ts", - "packages/core/src/render3/interfaces/view.ts", + "packages/core/src/core.ts", "packages/core/src/metadata.ts", "packages/core/src/di.ts", "packages/core/src/di/index.ts", @@ -1766,27 +1765,25 @@ [ "packages/core/src/render3/interfaces/container.ts", "packages/core/src/render3/interfaces/node.ts", - "packages/core/src/render3/interfaces/definition.ts", "packages/core/src/render3/interfaces/view.ts" ], [ "packages/core/src/render3/interfaces/definition.ts", - "packages/core/src/render3/interfaces/node.ts" + "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/view.ts" ], [ "packages/core/src/render3/interfaces/definition.ts", "packages/core/src/render3/interfaces/view.ts" ], [ - "packages/core/src/render3/interfaces/definition.ts", - "packages/core/src/render3/interfaces/view.ts", - "packages/core/src/render3/interfaces/node.ts" + "packages/core/src/render3/interfaces/node.ts", + "packages/core/src/render3/interfaces/view.ts" ], [ - "packages/core/src/render3/interfaces/definition.ts", + "packages/core/src/render3/interfaces/node.ts", "packages/core/src/render3/interfaces/view.ts", - "packages/core/src/render3/interfaces/query.ts", - "packages/core/src/render3/interfaces/node.ts" + "packages/core/src/render3/interfaces/query.ts" ], [ "packages/core/src/render3/interfaces/query.ts", diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 52acd55885..2f69e3ce40 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -62,7 +62,7 @@ "bundle": "TODO(i): we should define ngDevMode to false in Closure, but --define only works in the global scope.", "bundle": "TODO(i): (FW-2164) TS 3.9 new class shape seems to have broken Closure in big ways. The size went from 169991 to 252338", "bundle": "TODO(i): after removal of tsickle from ngc-wrapped / ng_package, we had to switch to SIMPLE optimizations which increased the size from 252338 to 1198917, see PR#37221 and PR#37317 for more info", - "bundle": 1212027 + "bundle": 1213130 } } } diff --git a/packages/core/src/render3/instructions/lview_debug.ts b/packages/core/src/render3/instructions/lview_debug.ts index 50c592d28c..7ea4307567 100644 --- a/packages/core/src/render3/instructions/lview_debug.ts +++ b/packages/core/src/render3/instructions/lview_debug.ts @@ -15,15 +15,14 @@ import {createNamedArrayType} from '../../util/named_array_type'; import {initNgDevMode} from '../../util/ng_dev_mode'; import {CONTAINER_HEADER_OFFSET, HAS_TRANSPLANTED_VIEWS, LContainer, MOVED_VIEWS, NATIVE} from '../interfaces/container'; import {DirectiveDefList, PipeDefList, ViewQueriesFunction} from '../interfaces/definition'; -import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, TIcu} from '../interfaces/i18n'; -import {PropertyAliases, TConstants, TContainerNode, TElementNode, TNode as ITNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TViewNode} from '../interfaces/node'; +import {PropertyAliases, TConstants, TContainerNode, TElementNode, TNode as ITNode, TNodeFlags, TNodeProviderIndexes, TNodeType, TNodeTypeAsString, TViewNode} from '../interfaces/node'; import {SelectorFlags} from '../interfaces/projection'; import {LQueries, TQueries} from '../interfaces/query'; import {RComment, RElement, Renderer3, RendererFactory3, RNode} from '../interfaces/renderer'; import {getTStylingRangeNext, getTStylingRangeNextDuplicate, getTStylingRangePrev, getTStylingRangePrevDuplicate, TStylingKey, TStylingRange} from '../interfaces/styling'; -import {CHILD_HEAD, CHILD_TAIL, CLEANUP, CONTEXT, DECLARATION_VIEW, DestroyHookData, ExpandoInstructions, FLAGS, HEADER_OFFSET, HookData, HOST, INJECTOR, LView, LViewFlags, NEXT, PARENT, QUERIES, RENDERER, RENDERER_FACTORY, SANITIZER, T_HOST, TData, TVIEW, TView as ITView, TView, TViewType} from '../interfaces/view'; +import {CHILD_HEAD, CHILD_TAIL, CLEANUP, CONTEXT, DebugNode, DECLARATION_VIEW, DestroyHookData, ExpandoInstructions, FLAGS, HEADER_OFFSET, HookData, HOST, INJECTOR, LContainerDebug as ILContainerDebug, LView, LViewDebug as ILViewDebug, LViewDebugRange, LViewDebugRangeContent, LViewFlags, NEXT, PARENT, QUERIES, RENDERER, RENDERER_FACTORY, SANITIZER, T_HOST, TData, TView as ITView, TVIEW, TView, TViewType} from '../interfaces/view'; import {attachDebugObject} from '../util/debug_utils'; -import {getTNode, unwrapRNode} from '../util/view_utils'; +import {unwrapRNode} from '../util/view_utils'; const NG_DEV_MODE = ((typeof ngDevMode === 'undefined' || !!ngDevMode) && initNgDevMode()); @@ -143,7 +142,10 @@ export const TViewConstructor = class TView implements ITView { public firstChild: ITNode|null, // public schemas: SchemaMetadata[]|null, // public consts: TConstants|null, // - public incompleteFirstPass: boolean // + public incompleteFirstPass: boolean, // + public _decls: number, // + public _vars: number, // + ) {} get template_(): string { @@ -335,9 +337,9 @@ export function attachLContainerDebug(lContainer: LContainer) { attachDebugObject(lContainer, new LContainerDebug(lContainer)); } -export function toDebug(obj: LView): LViewDebug; -export function toDebug(obj: LView|null): LViewDebug|null; -export function toDebug(obj: LView|LContainer|null): LViewDebug|LContainerDebug|null; +export function toDebug(obj: LView): ILViewDebug; +export function toDebug(obj: LView|null): ILViewDebug|null; +export function toDebug(obj: LView|LContainer|null): ILViewDebug|ILContainerDebug|null; export function toDebug(obj: any): any { if (obj) { const debug = (obj as any).debug; @@ -375,7 +377,7 @@ function toHtml(value: any, includeChildren: boolean = false): string|null { } } -export class LViewDebug { +export class LViewDebug implements ILViewDebug { constructor(private readonly _raw_lView: LView) {} /** @@ -396,10 +398,10 @@ export class LViewDebug { indexWithinInitPhase: flags >> LViewFlags.IndexWithinInitPhaseShift, }; } - get parent(): LViewDebug|LContainerDebug|null { + get parent(): ILViewDebug|ILContainerDebug|null { return toDebug(this._raw_lView[PARENT]); } - get host(): string|null { + get hostHTML(): string|null { return toHtml(this._raw_lView[HOST], true); } get html(): string { @@ -410,10 +412,9 @@ export class LViewDebug { } /** * The tree of nodes associated with the current `LView`. The nodes have been normalized into - * a - * tree structure with relevant details pulled out for readability. + * a tree structure with relevant details pulled out for readability. */ - get nodes(): DebugNode[]|null { + get nodes(): DebugNode[] { const lView = this._raw_lView; const tNode = lView[TVIEW].firstChild; return toDebugNodes(tNode, lView); @@ -437,16 +438,16 @@ export class LViewDebug { get sanitizer(): Sanitizer|null { return this._raw_lView[SANITIZER]; } - get childHead(): LViewDebug|LContainerDebug|null { + get childHead(): ILViewDebug|ILContainerDebug|null { return toDebug(this._raw_lView[CHILD_HEAD]); } - get next(): LViewDebug|LContainerDebug|null { + get next(): ILViewDebug|ILContainerDebug|null { return toDebug(this._raw_lView[NEXT]); } - get childTail(): LViewDebug|LContainerDebug|null { + get childTail(): ILViewDebug|ILContainerDebug|null { return toDebug(this._raw_lView[CHILD_TAIL]); } - get declarationView(): LViewDebug|null { + get declarationView(): ILViewDebug|null { return toDebug(this._raw_lView[DECLARATION_VIEW]); } get queries(): LQueries|null { @@ -456,11 +457,35 @@ export class LViewDebug { return this._raw_lView[T_HOST]; } + get decls(): LViewDebugRange { + const tView = this.tView as any as {_decls: number, _vars: number}; + const start = HEADER_OFFSET; + return toLViewRange(this.tView, this._raw_lView, start, start + tView._decls); + } + + get vars(): LViewDebugRange { + const tView = this.tView as any as {_decls: number, _vars: number}; + const start = HEADER_OFFSET + tView._decls; + return toLViewRange(this.tView, this._raw_lView, start, start + tView._vars); + } + + get i18n(): LViewDebugRange { + const tView = this.tView as any as {_decls: number, _vars: number}; + const start = HEADER_OFFSET + tView._decls + tView._vars; + return toLViewRange(this.tView, this._raw_lView, start, this.tView.expandoStartIndex); + } + + get expando(): LViewDebugRange { + const tView = this.tView as any as {_decls: number, _vars: number}; + return toLViewRange( + this.tView, this._raw_lView, this.tView.expandoStartIndex, this._raw_lView.length); + } + /** * Normalized view of child views (and containers) attached at this location. */ - get childViews(): Array { - const childViews: Array = []; + get childViews(): Array { + const childViews: Array = []; let child = this.childHead; while (child) { childViews.push(child); @@ -470,11 +495,12 @@ export class LViewDebug { } } -export interface DebugNode { - html: string|null; - native: Node; - nodes: DebugNode[]|null; - component: LViewDebug|null; +function toLViewRange(tView: TView, lView: LView, start: number, end: number): LViewDebugRange { + let content: LViewDebugRangeContent[] = []; + for (let index = start; index < end; index++) { + content.push({index: index, t: tView.data[index], l: lView[index]}); + } + return {start: start, end: end, length: end - start, content: content}; } /** @@ -483,7 +509,7 @@ export interface DebugNode { * @param tNode * @param lView */ -export function toDebugNodes(tNode: ITNode|null, lView: LView): DebugNode[]|null { +export function toDebugNodes(tNode: ITNode|null, lView: LView): DebugNode[] { if (tNode) { const debugNodes: DebugNode[] = []; let tNodeCursor: ITNode|null = tNode; @@ -493,33 +519,32 @@ export function toDebugNodes(tNode: ITNode|null, lView: LView): DebugNode[]|null } return debugNodes; } else { - return null; + return []; } } export function buildDebugNode(tNode: ITNode, lView: LView, nodeIndex: number): DebugNode { const rawValue = lView[nodeIndex]; const native = unwrapRNode(rawValue); - const componentLViewDebug = toDebug(readLViewValue(rawValue)); return { html: toHtml(native), + type: TNodeTypeAsString[tNode.type], native: native as any, - nodes: toDebugNodes(tNode.child, lView), - component: componentLViewDebug, + children: toDebugNodes(tNode.child, lView), }; } -export class LContainerDebug { +export class LContainerDebug implements ILContainerDebug { constructor(private readonly _raw_lContainer: LContainer) {} get hasTransplantedViews(): boolean { return this._raw_lContainer[HAS_TRANSPLANTED_VIEWS]; } - get views(): LViewDebug[] { + get views(): ILViewDebug[] { return this._raw_lContainer.slice(CONTAINER_HEADER_OFFSET) - .map(toDebug as (l: LView) => LViewDebug); + .map(toDebug as (l: LView) => ILViewDebug); } - get parent(): LViewDebug|LContainerDebug|null { + get parent(): ILViewDebug|null { return toDebug(this._raw_lContainer[PARENT]); } get movedViews(): LView[]|null { diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index e648853793..929a5fa78c 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -699,7 +699,9 @@ export function createTView( null, // firstChild: TNode|null, schemas, // schemas: SchemaMetadata[]|null, consts, // consts: TConstants|null - false // incompleteFirstPass: boolean + false, // incompleteFirstPass: boolean + decls, // ngDevMode only: decls + vars, // ngDevMode only: vars ) : { type: type, diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index e78ca97129..4684232065 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -7,14 +7,11 @@ */ import {KeyValueArray} from '../../util/array_utils'; import {TStylingRange} from '../interfaces/styling'; - -import {DirectiveDef} from './definition'; import {CssSelector} from './projection'; import {RNode} from './renderer'; import {LView, TView} from './view'; - /** * TNodeType corresponds to the {@link TNode} `type` property. */ @@ -45,6 +42,20 @@ export const enum TNodeType { IcuContainer = 5, } +/** + * Converts `TNodeType` into human readable text. + * Make sure this matches with `TNodeType` + */ +export const TNodeTypeAsString = [ + 'Container', // 0 + 'Projection', // 1 + 'View', // 2 + 'Element', // 3 + 'ElementContainer', // 4 + 'IcuContainer' // 5 +] as const; + + /** * Corresponds to the TNode.flags property. */ diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index b51d1976f9..0aec9344b3 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -15,10 +15,10 @@ import {Sanitizer} from '../../sanitization/sanitizer'; import {LContainer} from './container'; import {ComponentDef, ComponentTemplate, DirectiveDef, DirectiveDefList, HostBindingsFunction, PipeDef, PipeDefList, ViewQueriesFunction} from './definition'; import {I18nUpdateOpCodes, TI18n} from './i18n'; -import {TConstants, TElementNode, TNode, TViewNode} from './node'; +import {TConstants, TElementNode, TNode, TNodeTypeAsString, TViewNode} from './node'; import {PlayerHandler} from './player'; import {LQueries, TQueries} from './query'; -import {RElement, Renderer3, RendererFactory3} from './renderer'; +import {RComment, RElement, Renderer3, RendererFactory3} from './renderer'; import {TStylingKey, TStylingRange} from './styling'; @@ -69,6 +69,15 @@ export interface OpaqueViewState { * don't have to edit the data array based on which views are present. */ export interface LView extends Array { + /** + * Human readable representation of the `LView`. + * + * NOTE: This property only exists if `ngDevMode` is set to `true` and it is not present in + * production. Its presence is purely to help debug issue in development, and should not be relied + * on in production application. + */ + debug?: LViewDebug; + /** * The host node for this LView instance, if this is a component view. * If this is an embedded view, HOST will be null. @@ -826,3 +835,190 @@ export type TData = // Note: This hack is necessary so we don't erroneously get a circular dependency // failure based on types. export const unusedValueExportToPlacateAjd = 1; + +/** + * Human readable version of the `LView`. + * + * `LView` is a data structure used internally to keep track of views. The `LView` is designed for + * efficiency and so at times it is difficult to read or write tests which assert on its values. For + * this reason when `ngDevMode` is true we patch a `LView.debug` property which points to + * `LViewDebug` for easier debugging and test writing. It is the intent of `LViewDebug` to be used + * in tests. + */ +export interface LViewDebug { + /** + * Flags associated with the `LView` unpacked into a more readable state. + * + * See `LViewFlags` for the flag meanings. + */ + readonly flags: { + initPhaseState: number, + creationMode: boolean, + firstViewPass: boolean, + checkAlways: boolean, + dirty: boolean, + attached: boolean, + destroyed: boolean, + isRoot: boolean, + indexWithinInitPhase: number, + }; + + /** + * Parent view (or container) + */ + readonly parent: LViewDebug|LContainerDebug|null; + + /** + * Next sibling to the `LView`. + */ + readonly next: LViewDebug|LContainerDebug|null; + + /** + * The context used for evaluation of the `LView` + * + * (Usually the component) + */ + readonly context: {}|null; + + /** + * Hierarchical tree of nodes. + */ + readonly nodes: DebugNode[]; + + /** + * HTML representation of the `LView`. + * + * This is only approximate to actual HTML as child `LView`s are removed. + */ + readonly html: string; + + /** + * The host element to which this `LView` is attached. + */ + readonly hostHTML: string|null; + + /** + * Child `LView`s + */ + readonly childViews: Array; + + /** + * Sub range of `LView` containing decls (DOM elements). + */ + readonly decls: LViewDebugRange; + + /** + * Sub range of `LView` containing vars (bindings). + */ + readonly vars: LViewDebugRange; + + /** + * Sub range of `LView` containing i18n (translated DOM elements). + */ + readonly i18n: LViewDebugRange; + + /** + * Sub range of `LView` containing expando (used by DI). + */ + readonly expando: LViewDebugRange; +} + +/** + * Human readable version of the `LContainer` + * + * `LContainer` is a data structure used internally to keep track of child views. The `LContainer` + * is designed for efficiency and so at times it is difficult to read or write tests which assert on + * its values. For this reason when `ngDevMode` is true we patch a `LContainer.debug` property which + * points to `LContainerDebug` for easier debugging and test writing. It is the intent of + * `LContainerDebug` to be used in tests. + */ +export interface LContainerDebug { + readonly native: RComment; + /** + * Child `LView`s. + */ + readonly views: LViewDebug[]; + readonly parent: LViewDebug|null; + readonly movedViews: LView[]|null; + readonly host: RElement|RComment|LView; + readonly next: LViewDebug|LContainerDebug|null; + readonly hasTransplantedViews: boolean; +} + + + +/** + * `LView` is subdivided to ranges where the actual data is stored. Some of these ranges such as + * `decls` and `vars` are known at compile time. Other such as `i18n` and `expando` are runtime only + * concepts. + */ +export interface LViewDebugRange { + /** + * The starting index in `LView` where the range begins. (Inclusive) + */ + start: number; + + /** + * The ending index in `LView` where the range ends. (Exclusive) + */ + end: number; + + /** + * The length of the range + */ + length: number; + + /** + * The merged content of the range. `t` contains data from `TView.data` and `l` contains `LView` + * data at an index. + */ + content: LViewDebugRangeContent[]; +} + +/** + * For convenience the static and instance portions of `TView` and `LView` are merged into a single + * object in `LViewRange`. + */ +export interface LViewDebugRangeContent { + /** + * Index into original `LView` or `TView.data`. + */ + index: number; + + /** + * Value from the `TView.data[index]` location. + */ + t: any; + + /** + * Value from the `LView[index]` location. + */ + l: any; +} + + +/** + * A logical node which comprise into `LView`s. + * + */ +export interface DebugNode { + /** + * HTML representation of the node. + */ + html: string|null; + + /** + * Human readable node type. + */ + type: typeof TNodeTypeAsString[number]; + + /** + * DOM native node. + */ + native: Node; + + /** + * Child nodes + */ + children: DebugNode[]; +} \ No newline at end of file diff --git a/packages/core/src/render3/util/discovery_utils.ts b/packages/core/src/render3/util/discovery_utils.ts index 983bcdf0c9..94de7d03d5 100644 --- a/packages/core/src/render3/util/discovery_utils.ts +++ b/packages/core/src/render3/util/discovery_utils.ts @@ -10,12 +10,12 @@ import {Injector} from '../../di/injector'; import {assertLView} from '../assert'; import {discoverLocalRefs, getComponentAtNodeIndex, getDirectivesAtNodeIndex, getLContext} from '../context_discovery'; import {NodeInjector} from '../di'; -import {buildDebugNode, DebugNode} from '../instructions/lview_debug'; +import {buildDebugNode} from '../instructions/lview_debug'; import {LContext} from '../interfaces/context'; import {DirectiveDef} from '../interfaces/definition'; import {TElementNode, TNode, TNodeProviderIndexes} from '../interfaces/node'; import {isLView} from '../interfaces/type_checks'; -import {CLEANUP, CONTEXT, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, T_HOST, TVIEW} from '../interfaces/view'; +import {CLEANUP, CONTEXT, DebugNode, FLAGS, HEADER_OFFSET, HOST, LView, LViewFlags, T_HOST, TVIEW} from '../interfaces/view'; import {stringifyForError} from './misc_utils'; import {getLViewParent, getRootContext} from './view_traversal_utils'; diff --git a/packages/core/test/acceptance/BUILD.bazel b/packages/core/test/acceptance/BUILD.bazel index b77a9a0169..a32706052a 100644 --- a/packages/core/test/acceptance/BUILD.bazel +++ b/packages/core/test/acceptance/BUILD.bazel @@ -20,6 +20,7 @@ ts_library( "//packages/compiler/testing", "//packages/core", "//packages/core/src/util", + "//packages/core/test/render3:matchers", "//packages/core/testing", "//packages/localize", "//packages/localize/init", diff --git a/packages/core/test/acceptance/debug_spec.ts b/packages/core/test/acceptance/debug_spec.ts index a64d6675e8..ef1e3d3ea3 100644 --- a/packages/core/test/acceptance/debug_spec.ts +++ b/packages/core/test/acceptance/debug_spec.ts @@ -8,13 +8,17 @@ import {Component} from '@angular/core'; import {getLContext} from '@angular/core/src/render3/context_discovery'; -import {LViewDebug, toDebug} from '@angular/core/src/render3/instructions/lview_debug'; +import {LViewDebug} from '@angular/core/src/render3/instructions/lview_debug'; +import {TNodeType} from '@angular/core/src/render3/interfaces/node'; +import {HEADER_OFFSET} from '@angular/core/src/render3/interfaces/view'; import {TestBed} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; import {onlyInIvy} from '@angular/private/testing'; -describe('Debug Representation', () => { - onlyInIvy('Ivy specific').it('should generate a human readable version', () => { +import {matchDomElement, matchDomText, matchTI18n, matchTNode} from '../render3/matchers'; + +onlyInIvy('Ivy specific').describe('Debug Representation', () => { + it('should generate a human readable version', () => { @Component({selector: 'my-comp', template: '
Hello World
'}) class MyComponent { } @@ -23,11 +27,56 @@ describe('Debug Representation', () => { const fixture = TestBed.createComponent(MyComponent); fixture.detectChanges(); - const hostView = toDebug(getLContext(fixture.componentInstance)!.lView); - expect(hostView.host).toEqual(null); + const hostView = getLContext(fixture.componentInstance)!.lView.debug!; + expect(hostView.hostHTML).toEqual(null); const myCompView = hostView.childViews[0] as LViewDebug; - expect(myCompView.host).toContain('
Hello World
'); + expect(myCompView.hostHTML).toContain('
Hello World
'); expect(myCompView.nodes![0].html).toEqual('
'); - expect(myCompView.nodes![0].nodes![0].html).toEqual('Hello World'); + expect(myCompView.nodes![0].children![0].html).toEqual('Hello World'); + }); + + describe('LViewDebug', () => { + describe('range', () => { + it('should show ranges', () => { + @Component({selector: 'my-comp', template: '
Hello {{name}}
'}) + class MyComponent { + name = 'World'; + } + + TestBed.configureTestingModule({declarations: [MyComponent]}); + const fixture = TestBed.createComponent(MyComponent); + fixture.detectChanges(); + + const hostView = getLContext(fixture.componentInstance)!.lView.debug!; + const myComponentView = hostView.childViews[0] as LViewDebug; + expect(myComponentView.decls).toEqual({ + start: HEADER_OFFSET, + end: HEADER_OFFSET + 2, + length: 2, + content: [ + {index: HEADER_OFFSET + 0, t: matchTNode({tagName: 'div'}), l: matchDomElement('div')}, + {index: HEADER_OFFSET + 1, t: matchTI18n(), l: null}, + ] + }); + expect(myComponentView.vars).toEqual({ + start: HEADER_OFFSET + 2, + end: HEADER_OFFSET + 3, + length: 1, + content: [{index: HEADER_OFFSET + 2, t: null, l: 'World'}] + }); + expect(myComponentView.i18n).toEqual({ + start: HEADER_OFFSET + 3, + end: HEADER_OFFSET + 4, + length: 1, + content: [{ + index: HEADER_OFFSET + 3, + t: matchTNode({type: TNodeType.Element, tagName: null}), + l: matchDomText('Hello World') + }] + }); + expect(myComponentView.expando) + .toEqual({start: HEADER_OFFSET + 4, end: HEADER_OFFSET + 4, length: 0, content: []}); + }); + }); }); }); diff --git a/packages/core/test/render3/BUILD.bazel b/packages/core/test/render3/BUILD.bazel index 4886dc17ac..66855c2a1d 100644 --- a/packages/core/test/render3/BUILD.bazel +++ b/packages/core/test/render3/BUILD.bazel @@ -11,10 +11,13 @@ ts_library( "**/*_perf.ts", "domino.d.ts", "load_domino.ts", + "is_shape_of.ts", "jit_spec.ts", + "matchers.ts", ], ), deps = [ + ":matchers", "//packages:types", "//packages/animations", "//packages/animations/browser", @@ -34,6 +37,18 @@ ts_library( ], ) +ts_library( + name = "matchers", + testonly = True, + srcs = [ + "is_shape_of.ts", + "matchers.ts", + ], + deps = [ + "//packages/core", + ], +) + ts_library( name = "domino", testonly = True, diff --git a/packages/core/test/render3/interfaces/node_spec.ts b/packages/core/test/render3/interfaces/node_spec.ts new file mode 100644 index 0000000000..cd2a3a1516 --- /dev/null +++ b/packages/core/test/render3/interfaces/node_spec.ts @@ -0,0 +1,22 @@ +/** + * @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.io/license + */ + +import {TNodeType, TNodeTypeAsString} from '@angular/core/src/render3/interfaces/node'; + +describe('node interfaces', () => { + describe('TNodeType', () => { + it('should agree with TNodeTypeAsString', () => { + expect(TNodeTypeAsString[TNodeType.Container]).toEqual('Container'); + expect(TNodeTypeAsString[TNodeType.Projection]).toEqual('Projection'); + expect(TNodeTypeAsString[TNodeType.View]).toEqual('View'); + expect(TNodeTypeAsString[TNodeType.Element]).toEqual('Element'); + expect(TNodeTypeAsString[TNodeType.ElementContainer]).toEqual('ElementContainer'); + expect(TNodeTypeAsString[TNodeType.IcuContainer]).toEqual('IcuContainer'); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/test/render3/is_shape_of.ts b/packages/core/test/render3/is_shape_of.ts new file mode 100644 index 0000000000..86bbf66b86 --- /dev/null +++ b/packages/core/test/render3/is_shape_of.ts @@ -0,0 +1,186 @@ +/** + * @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.io/license + */ + +import {TI18n} from '@angular/core/src/render3/interfaces/i18n'; +import {TNode} from '@angular/core/src/render3/interfaces/node'; +import {TView} from '@angular/core/src/render3/interfaces/view'; + +/** + * A type used to create a runtime representation of a shape of object which matches the declared + * interface at compile time. + * + * The purpose of this type is to ensure that the object must match all of the properties of a type. + * This is later used by `isShapeOf` method to ensure that a particular object has a particular + * shape. + * + * ``` + * interface MyShape { + * foo: string, + * bar: number + * } + * + * const myShapeObj: {foo: '', bar: 0}; + * const ExpectedPropertiesOfShape = {foo: true, bar: true}; + * + * isShapeOf(myShapeObj, ExpectedPropertiesOfShape); + * ``` + * + * The above code would verify that `myShapeObj` has `foo` and `bar` properties. However if later + * `MyShape` is refactored to change a set of properties we would like to have a compile time error + * that the `ExpectedPropertiesOfShape` also needs to be changed. + * + * ``` + * const ExpectedPropertiesOfShape = >{foo: true, bar: true}; + * ``` + * The above code will force through compile time checks that the `ExpectedPropertiesOfShape` match + * that of `MyShape`. + * + * See: `isShapeOf` + * + */ +export type ShapeOf = { + [P in keyof T]: true; +}; + +/** + * Determines if a particular object is of a given shape (duck-type version of `instanceof`.) + * + * ``` + * isShapeOf(someObj, {foo: true, bar: true}); + * ``` + * + * The above code will be true if the `someObj` has both `foo` and `bar` property + * + * @param obj Object to test for. + * @param shapeOf Desired shape. + */ +export function isShapeOf(obj: any, shapeOf: ShapeOf): obj is T { + if (typeof obj === 'object' && obj) { + return Object.keys(shapeOf).reduce( + (prev, key) => prev && obj.hasOwnProperty(key), true as boolean); + } + return false; +} + +/** + * Determines if `obj` matches the shape `TI18n`. + * @param obj + */ +export function isTI18n(obj: any): obj is TI18n { + return isShapeOf(obj, ShapeOfTI18n); +} +const ShapeOfTI18n: ShapeOf = { + vars: true, + create: true, + update: true, + icus: true, +}; + + +/** + * Determines if `obj` matches the shape `TView`. + * @param obj + */ +export function isTView(obj: any): obj is TView { + return isShapeOf(obj, ShapeOfTView); +} +const ShapeOfTView: ShapeOf = { + type: true, + id: true, + blueprint: true, + template: true, + viewQuery: true, + node: true, + firstCreatePass: true, + firstUpdatePass: true, + data: true, + bindingStartIndex: true, + expandoStartIndex: true, + staticViewQueries: true, + staticContentQueries: true, + firstChild: true, + expandoInstructions: true, + directiveRegistry: true, + pipeRegistry: true, + preOrderHooks: true, + preOrderCheckHooks: true, + contentHooks: true, + contentCheckHooks: true, + viewHooks: true, + viewCheckHooks: true, + destroyHooks: true, + cleanup: true, + components: true, + queries: true, + contentQueries: true, + schemas: true, + consts: true, + incompleteFirstPass: true, +}; + + +/** + * Determines if `obj` matches the shape `TI18n`. + * @param obj + */ +export function isTNode(obj: any): obj is TNode { + return isShapeOf(obj, ShapeOfTNode); +} +const ShapeOfTNode: ShapeOf = { + type: true, + index: true, + injectorIndex: true, + directiveStart: true, + directiveEnd: true, + directiveStylingLast: true, + propertyBindings: true, + flags: true, + providerIndexes: true, + tagName: true, + attrs: true, + mergedAttrs: true, + localNames: true, + initialInputs: true, + inputs: true, + outputs: true, + tViews: true, + next: true, + projectionNext: true, + child: true, + parent: true, + projection: true, + styles: true, + stylesWithoutHost: true, + residualStyles: true, + classes: true, + classesWithoutHost: true, + residualClasses: true, + classBindings: true, + styleBindings: true, +}; + +/** + * Determines if `obj` is DOM `Node`. + */ +export function isDOMNode(obj: any): obj is Node { + return obj instanceof Node; +} + +/** + * Determines if `obj` is DOM `Text`. + */ +export function isDOMElement(obj: any): obj is Element { + return obj instanceof Element; +} + +/** + * Determines if `obj` is DOM `Text`. + */ +export function isDOMText(obj: any): obj is Text { + return obj instanceof Text; +} \ No newline at end of file diff --git a/packages/core/test/render3/is_shape_of_spec.ts b/packages/core/test/render3/is_shape_of_spec.ts new file mode 100644 index 0000000000..43818a396d --- /dev/null +++ b/packages/core/test/render3/is_shape_of_spec.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.io/license + */ + +import {isShapeOf, ShapeOf} from './is_shape_of'; + +describe('isShapeOf', () => { + const ShapeOfEmptyObject: ShapeOf<{}> = {}; + it('should not match for non objects', () => { + expect(isShapeOf(null, ShapeOfEmptyObject)).toBeFalse(); + expect(isShapeOf(0, ShapeOfEmptyObject)).toBeFalse(); + expect(isShapeOf(1, ShapeOfEmptyObject)).toBeFalse(); + expect(isShapeOf(true, ShapeOfEmptyObject)).toBeFalse(); + expect(isShapeOf(false, ShapeOfEmptyObject)).toBeFalse(); + expect(isShapeOf(undefined, ShapeOfEmptyObject)).toBeFalse(); + }); + + it('should match on empty object', () => { + expect(isShapeOf({}, ShapeOfEmptyObject)).toBeTrue(); + expect(isShapeOf({extra: 'is ok'}, ShapeOfEmptyObject)).toBeTrue(); + }); + + it('should match on shape', () => { + expect(isShapeOf({required: 1}, {required: true})).toBeTrue(); + expect(isShapeOf({required: true, extra: 'is ok'}, {required: true})).toBeTrue(); + }); + + it('should not match if missing property', () => { + expect(isShapeOf({required: 1}, {required: true, missing: true})).toBeFalse(); + expect(isShapeOf({required: true, extra: 'is ok'}, {required: true, missing: true})) + .toBeFalse(); + }); +}); \ No newline at end of file diff --git a/packages/core/test/render3/matchers.ts b/packages/core/test/render3/matchers.ts new file mode 100644 index 0000000000..59826612ab --- /dev/null +++ b/packages/core/test/render3/matchers.ts @@ -0,0 +1,218 @@ +/** + * @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.io/license + */ + +import {TI18n} from '@angular/core/src/render3/interfaces/i18n'; +import {TNode} from '@angular/core/src/render3/interfaces/node'; +import {TView} from '@angular/core/src/render3/interfaces/view'; + +import {isDOMElement, isDOMText, isTI18n, isTNode, isTView} from './is_shape_of'; + + +/** + * Generic matcher which asserts that an object is of a given shape (`shapePredicate`) and that it + * contains a subset of properties. + * + * @param name Name of `shapePredicate` to display when assertion fails. + * @param shapePredicate Predicate which verifies that the object is of correct shape. + * @param expected Expected set of properties to be found on the object. + */ +export function matchObjectShape( + name: string, shapePredicate: (obj: any) => obj is T, + expected: Partial = {}): jasmine.AsymmetricMatcher { + const matcher = function() {}; + let _actual: any = null; + + matcher.asymmetricMatch = function(actual: any) { + _actual = actual; + if (!shapePredicate(actual)) return false; + for (const key in expected) { + if (expected.hasOwnProperty(key) && !jasmine.matchersUtil.equals(actual[key], expected[key])) + return false; + } + return true; + }; + matcher.jasmineToString = function() { + return `${toString(_actual, false)} != ${toString(expected, true)})`; + }; + + function toString(obj: any, isExpected: boolean) { + if (isExpected || shapePredicate(obj)) { + const props = + Object.keys(expected).map(key => `${key}: ${JSON.stringify((obj as any)[key])}`); + if (isExpected === false) { + // Push something to let the user know that there may be other ignored properties in actual + props.push('...'); + } + return `${name}({${props.length === 0 ? '' : '\n ' + props.join(',\n ') + '\n'}})`; + } else { + return JSON.stringify(obj); + } + } + return matcher; +} + + +/** + * Asymmetric matcher which matches a `TView` of a given shape. + * + * Expected usage: + * ``` + * expect(tNode).toEqual(matchTView({type: TViewType.Root})); + * expect({ + * node: tNode + * }).toEqual({ + * node: matchTNode({type: TViewType.Root}) + * }); + * ``` + * + * @param expected optional properties which the `TView` must contain. + */ +export function matchTView(expected?: Partial): jasmine.AsymmetricMatcher { + return matchObjectShape('TView', isTView, expected); +} + +/** + * Asymmetric matcher which matches a `TNode` of a given shape. + * + * Expected usage: + * ``` + * expect(tNode).toEqual(matchTNode({type: TNodeType.Element})); + * expect({ + * node: tNode + * }).toEqual({ + * node: matchTNode({type: TNodeType.Element}) + * }); + * ``` + * + * @param expected optional properties which the `TNode` must contain. + */ +export function matchTNode(expected?: Partial): jasmine.AsymmetricMatcher { + return matchObjectShape('TNode', isTNode, expected); +} + + +/** + * Asymmetric matcher which matches a `T18n` of a given shape. + * + * Expected usage: + * ``` + * expect(tNode).toEqual(matchT18n({vars: 0})); + * expect({ + * node: tNode + * }).toEqual({ + * node: matchT18n({vars: 0}) + * }); + * ``` + * + * @param expected optional properties which the `TI18n` must contain. + */ +export function matchTI18n(expected?: Partial): jasmine.AsymmetricMatcher { + return matchObjectShape('TI18n', isTI18n, expected); +} + + + +/** + * Asymmetric matcher which matches a DOM Element. + * + * Expected usage: + * ``` + * expect(div).toEqual(matchT18n('div', {id: '123'})); + * expect({ + * node: div + * }).toEqual({ + * node: matchT18n('div', {id: '123'}) + * }); + * ``` + * + * @param expectedTagName optional DOM tag name. + * @param expectedAttributes optional DOM element properties. + */ +export function matchDomElement( + expectedTagName: string|undefined = undefined, + expectedAttrs: {[key: string]: string|null} = {}): jasmine.AsymmetricMatcher { + const matcher = function() {}; + let _actual: any = null; + + matcher.asymmetricMatch = function(actual: any) { + _actual = actual; + if (!isDOMElement(actual)) return false; + if (expectedTagName && (expectedTagName.toUpperCase() !== actual.tagName.toUpperCase())) { + return false; + } + if (expectedAttrs) { + for (const attrName in expectedAttrs) { + if (expectedAttrs.hasOwnProperty(attrName)) { + const expectedAttrValue = expectedAttrs[attrName]; + const actualAttrValue = actual.getAttribute(attrName); + if (expectedAttrValue !== actualAttrValue) { + return false; + } + } + } + } + return true; + }; + matcher.jasmineToString = function() { + let actualStr = isDOMElement(_actual) ? `<${_actual.tagName}${toString(_actual.attributes)}>` : + JSON.stringify(_actual); + let expectedStr = `<${expectedTagName || '*'}${ + Object.keys(expectedAttrs).map(key => ` ${key}=${JSON.stringify(expectedAttrs[key])}`)}>`; + return `[${actualStr} != ${expectedStr}]`; + }; + + function toString(attrs: NamedNodeMap) { + let text = ''; + for (let i = 0; i < attrs.length; i++) { + const attr = attrs[i]; + text += ` ${attr.name}=${JSON.stringify(attr.value)}`; + } + return text; + } + + + return matcher; +} + +/** + * Asymmetric matcher which matches DOM text node. + * + * Expected usage: + * ``` + * expect(div).toEqual(matchDomText('text')); + * expect({ + * node: div + * }).toEqual({ + * node: matchDomText('text') + * }); + * ``` + * + * @param expectedText optional DOM text. + */ +export function matchDomText(expectedText: string|undefined = undefined): + jasmine.AsymmetricMatcher { + const matcher = function() {}; + let _actual: any = null; + + matcher.asymmetricMatch = function(actual: any) { + _actual = actual; + if (!isDOMText(actual)) return false; + if (expectedText && (expectedText !== actual.textContent)) { + return false; + } + return true; + }; + matcher.jasmineToString = function() { + let actualStr = isDOMText(_actual) ? `#TEXT: ${JSON.stringify(_actual.textContent)}` : + JSON.stringify(_actual); + let expectedStr = `#TEXT: ${JSON.stringify(expectedText)}`; + return `[${actualStr} != ${expectedStr}]`; + }; + + return matcher; +} \ No newline at end of file diff --git a/packages/core/test/render3/matchers_spec.ts b/packages/core/test/render3/matchers_spec.ts new file mode 100644 index 0000000000..10170064ee --- /dev/null +++ b/packages/core/test/render3/matchers_spec.ts @@ -0,0 +1,101 @@ +/** + * @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.io/license + */ + +import {createTNode, createTView} from '@angular/core/src/render3/instructions/shared'; +import {TNodeType} from '@angular/core/src/render3/interfaces/node'; +import {TViewType} from '@angular/core/src/render3/interfaces/view'; +import {onlyInIvy} from '@angular/private/testing'; + +import {isShapeOf, ShapeOf} from './is_shape_of'; +import {matchDomElement, matchDomText, matchObjectShape, matchTNode, matchTView} from './matchers'; +import {dedent} from './utils'; + +describe('render3 matchers', () => { + describe('matchObjectShape', () => { + interface MyShape { + propA: any; + propB: any; + } + + const myShape: MyShape = {propA: 'value', propB: 3}; + function isMyShape(obj: any): obj is MyShape { + return isShapeOf(obj, ShapeOfMyShape); + } + const ShapeOfMyShape: ShapeOf = {propA: true, propB: true}; + function matchMyShape(expected?: Partial): jasmine.AsymmetricMatcher { + return matchObjectShape('MyShape', isMyShape, expected); + } + + it('should match', () => { + expect(isMyShape(myShape)).toBeTrue(); + expect(myShape).toEqual(matchMyShape()); + expect(myShape).toEqual(matchMyShape({propA: 'value'})); + expect({node: myShape}).toEqual({node: matchMyShape({propA: 'value'})}); + }); + + it('should produce human readable errors', () => { + const matcher = matchMyShape({propA: 'different'}); + expect(matcher.asymmetricMatch(myShape, [])).toEqual(false); + expect(matcher.jasmineToString!()).toEqual(dedent` + MyShape({ + propA: "value", + ... + }) != MyShape({ + propA: "different" + }))`); + }); + }); + + describe('matchTView', () => { + const tView = createTView(TViewType.Root, 1, null, 2, 3, null, null, null, null, null); + it('should match', () => { + expect(tView).toEqual(matchTView()); + expect(tView).toEqual(matchTView({type: TViewType.Root})); + expect({node: tView}).toEqual({node: matchTView({type: TViewType.Root})}); + }); + }); + describe('matchTNode', () => { + const tView = createTView(TViewType.Root, 1, null, 2, 3, null, null, null, null, null); + const tNode = createTNode(tView, null, TNodeType.Element, 1, 'tagName', []); + + it('should match', () => { + expect(tNode).toEqual(matchTNode()); + expect(tNode).toEqual(matchTNode({type: TNodeType.Element, tagName: 'tagName'})); + expect({node: tNode}).toEqual({node: matchTNode({type: TNodeType.Element})}); + }); + }); + + describe('matchDomElement', () => { + const div = document.createElement('div'); + div.setAttribute('name', 'Name'); + it('should match', () => { + expect(div).toEqual(matchDomElement()); + expect(div).toEqual(matchDomElement('div', {name: 'Name'})); + }); + + it('should produce human readable error', () => { + const matcher = matchDomElement('div', {name: 'other'}); + expect(matcher.asymmetricMatch(div, [])).toEqual(false); + expect(matcher.jasmineToString!()).toEqual(`[
!=
]`); + }); + }); + + describe('matchDomText', () => { + const text = document.createTextNode('myText'); + it('should match', () => { + expect(text).toEqual(matchDomText()); + expect(text).toEqual(matchDomText('myText')); + }); + + it('should produce human readable error', () => { + const matcher = matchDomText('other text'); + expect(matcher.asymmetricMatch(text, [])).toEqual(false); + expect(matcher.jasmineToString!()).toEqual(`[#TEXT: "myText" != #TEXT: "other text"]`); + }); + }); +}); \ No newline at end of file