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
This commit is contained in:
Misko Hevery 2020-08-05 19:16:20 -07:00 committed by Andrew Kushnir
parent df7f3b04b5
commit 702958e968
15 changed files with 937 additions and 77 deletions

View File

@ -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",

View File

@ -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
}
}
}

View File

@ -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<LViewDebug|LContainerDebug> {
const childViews: Array<LViewDebug|LContainerDebug> = [];
get childViews(): Array<ILViewDebug|ILContainerDebug> {
const childViews: Array<ILViewDebug|ILContainerDebug> = [];
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 {

View File

@ -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,

View File

@ -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.
*/

View File

@ -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<any> {
/**
* 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<LViewDebug|LContainerDebug>;
/**
* 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[];
}

View File

@ -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';

View File

@ -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",

View File

@ -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: '<div id="123">Hello World</div>'})
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('<div id="123">Hello World</div>');
expect(myCompView.hostHTML).toContain('<div id="123">Hello World</div>');
expect(myCompView.nodes![0].html).toEqual('<div id="123">');
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: '<div i18n>Hello {{name}}</div>'})
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: []});
});
});
});
});

View File

@ -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,

View File

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

View File

@ -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 = <ShapeOf<MyShape>>{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<T> = {
[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<T>(obj: any, shapeOf: ShapeOf<T>): 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<TI18n>(obj, ShapeOfTI18n);
}
const ShapeOfTI18n: ShapeOf<TI18n> = {
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<TView>(obj, ShapeOfTView);
}
const ShapeOfTView: ShapeOf<TView> = {
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<TNode>(obj, ShapeOfTNode);
}
const ShapeOfTNode: ShapeOf<TNode> = {
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;
}

View File

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

View File

@ -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<T>(
name: string, shapePredicate: (obj: any) => obj is T,
expected: Partial<T> = {}): jasmine.AsymmetricMatcher<T> {
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<TView>): jasmine.AsymmetricMatcher<TView> {
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<TNode>): jasmine.AsymmetricMatcher<TNode> {
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<TI18n>): jasmine.AsymmetricMatcher<TI18n> {
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<Element> {
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<Text> {
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;
}

View File

@ -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<MyShape>(obj, ShapeOfMyShape);
}
const ShapeOfMyShape: ShapeOf<MyShape> = {propA: true, propB: true};
function matchMyShape(expected?: Partial<MyShape>): jasmine.AsymmetricMatcher<MyShape> {
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(`[<DIV name="Name"> != <div name="other">]`);
});
});
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"]`);
});
});
});