From e81e5fb2b9127c4b93265806b2f2c462c8403127 Mon Sep 17 00:00:00 2001 From: Julie Ralph Date: Mon, 23 Mar 2015 16:46:18 -0700 Subject: [PATCH] feat(testability): add an initial scaffold for the testability api Make each application component register itself onto the testability API and exports the API onto the window object. --- modules/angular2/src/core/application.js | 14 ++- .../src/core/testability/get_testability.dart | 96 +++++++++++++++++++ .../src/core/testability/get_testability.es6 | 37 +++++++ .../src/core/testability/testability.js | 81 ++++++++++++++++ modules/angular2/src/dom/browser_adapter.dart | 4 + modules/angular2/src/dom/browser_adapter.es6 | 6 ++ modules/angular2/src/dom/dom_adapter.js | 6 ++ modules/angular2/src/dom/html_adapter.dart | 12 +++ modules/angular2/src/dom/parse5_adapter.cjs | 6 ++ .../angular2/test/core/application_spec.js | 17 ++++ .../test/core/testability/testability_spec.js | 42 ++++++++ .../src/largetable/largetable_benchmark.js | 13 +++ .../src/naive_infinite_scroll/index.js | 13 +++ modules/benchmarks/src/tree/tree_benchmark.js | 13 +++ .../examples/src/hello_world/index_static.js | 13 +++ 15 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 modules/angular2/src/core/testability/get_testability.dart create mode 100644 modules/angular2/src/core/testability/get_testability.es6 create mode 100644 modules/angular2/src/core/testability/testability.js create mode 100644 modules/angular2/test/core/testability/testability_spec.js diff --git a/modules/angular2/src/core/application.js b/modules/angular2/src/core/application.js index 0c95adef2f..fb1e29215e 100644 --- a/modules/angular2/src/core/application.js +++ b/modules/angular2/src/core/application.js @@ -27,12 +27,14 @@ import {StyleInliner} from 'angular2/src/core/compiler/style_inliner'; import {CssProcessor} from 'angular2/src/core/compiler/css_processor'; import {Component} from 'angular2/src/core/annotations/annotations'; import {PrivateComponentLoader} from 'angular2/src/core/compiler/private_component_loader'; +import {TestabilityRegistry, Testability} from 'angular2/src/core/testability/testability'; var _rootInjector: Injector; // Contains everything that is safe to share between applications. var _rootBindings = [ - bind(Reflector).toValue(reflector) + bind(Reflector).toValue(reflector), + TestabilityRegistry ]; export var appViewToken = new OpaqueToken('AppView'); @@ -57,9 +59,12 @@ function _injectorBindings(appComponentType): List { } return element; }, [appComponentAnnotatedTypeToken, appDocumentToken]), - bind(appViewToken).toAsyncFactory((changeDetection, compiler, injector, appElement, - appComponentAnnotatedType, strategy, eventManager) => { + appComponentAnnotatedType, strategy, eventManager, testability, registry) => { + + // We need to do this here to ensure that we create Testability and + // it's ready on the window for users. + registry.registerApplication(appElement, testability); var annotation = appComponentAnnotatedType.annotation; if(!isBlank(annotation) && !(annotation instanceof Component)) { var type = appComponentAnnotatedType.type; @@ -79,7 +84,7 @@ function _injectorBindings(appComponentType): List { return view; }); }, [ChangeDetection, Compiler, Injector, appElementToken, appComponentAnnotatedTypeToken, - ShadowDomStrategy, EventManager]), + ShadowDomStrategy, EventManager, Testability, TestabilityRegistry]), bind(appChangeDetectorToken).toFactory((rootView) => rootView.changeDetector, [appViewToken]), @@ -109,6 +114,7 @@ function _injectorBindings(appComponentType): List { StyleInliner, bind(CssProcessor).toFactory(() => new CssProcessor(null), []), PrivateComponentLoader, + Testability, ]; } diff --git a/modules/angular2/src/core/testability/get_testability.dart b/modules/angular2/src/core/testability/get_testability.dart new file mode 100644 index 0000000000..5cc430f908 --- /dev/null +++ b/modules/angular2/src/core/testability/get_testability.dart @@ -0,0 +1,96 @@ +library testability.get_testability; + +import './testability.dart'; + +import 'dart:html'; +import 'dart:js' as js; + +// Work around http://dartbug.com/17752, copied from +// https://github.com/angular/angular.dart/blob/master/lib/introspection.dart +// Proxies a Dart function that accepts up to 10 parameters. +js.JsFunction _jsFunction(Function fn) { + const Object X = __varargSentinel; + return new js.JsFunction.withThis( + (thisArg, [o1=X, o2=X, o3=X, o4=X, o5=X, o6=X, o7=X, o8=X, o9=X, o10=X]) { + return __invokeFn(fn, o1, o2, o3, o4, o5, o6, o7, o8, o9, o10); + }); +} + + +const Object __varargSentinel = const Object(); + + +__invokeFn(fn, o1, o2, o3, o4, o5, o6, o7, o8, o9, o10) { + var args = [o1, o2, o3, o4, o5, o6, o7, o8, o9, o10]; + while (args.length > 0 && identical(args.last, __varargSentinel)) { + args.removeLast(); + } + return _jsify(Function.apply(fn, args)); +} + + +// Helper function to JSify a Dart object. While this is *required* to JSify +// the result of a scope.eval(), other uses are not required and are used to +// work around http://dartbug.com/17752 in a convenient way (that bug affects +// dart2js in checked mode.) +_jsify(var obj) { + if (obj == null || obj is js.JsObject) { + return obj; + } + if (obj is _JsObjectProxyable) { + return obj._toJsObject(); + } + if (obj is Function) { + return _jsFunction(obj); + } + if ((obj is Map) || (obj is Iterable)) { + var mappedObj = (obj is Map) ? + new Map.fromIterables(obj.keys, obj.values.map(_jsify)) : obj.map(_jsify); + if (obj is List) { + return new js.JsArray.from(mappedObj); + } else { + return new js.JsObject.jsify(mappedObj); + } + } + return obj; +} + +abstract class _JsObjectProxyable { + js.JsObject _toJsObject(); +} + +class PublicTestability implements _JsObjectProxyable { + Testability _testability; + PublicTestability(Testability testability) { + this._testability = testability; + } + + whenStable(Function callback) { + return this._testability.whenStable(callback); + } + + findBindings(Element elem, String binding, bool exactMatch) { + return this._testability.findBindings(elem, binding, exactMatch); + } + + js.JsObject _toJsObject() { + return _jsify({ + 'findBindings': (bindingString, [exactMatch, allowNonElementNodes]) => + findBindings(bindingString, exactMatch, allowNonElementNodes), + 'whenStable': (callback) => + whenStable(() => callback.apply([])), + })..['_dart_'] = this; + } +} + +class GetTestability { + static addToWindow(TestabilityRegistry registry) { + js.context['angular2'] = _jsify({ + 'getTestability': (Element elem) { + Testability testability = registry.findTestabilityInTree(elem); + return _jsify(new PublicTestability(testability)); + }, + 'resumeBootstrap': ([arg]) {}, + }); + } +} diff --git a/modules/angular2/src/core/testability/get_testability.es6 b/modules/angular2/src/core/testability/get_testability.es6 new file mode 100644 index 0000000000..c7c6555dbb --- /dev/null +++ b/modules/angular2/src/core/testability/get_testability.es6 @@ -0,0 +1,37 @@ +import {TestabilityRegistry, Testability} from 'angular2/src/core/testability/testability'; + +class PublicTestability { + _testabililty: Testability; + + constructor(testability: Testability) { + this._testability = testability; + } + + whenStable(callback: Function) { + this._testability.whenStable(callback); + } + + findBindings(using, binding: string, exactMatch: boolean) { + return this._testability.findBindings(using, binding, exactMatch); + } +} + +export class GetTestability { + static addToWindow(registry: TestabilityRegistry) { + if (!window.angular2) { + window.angular2 = {}; + } + window.angular2.getTestability = function(elem): PublicTestability { + var testability = registry.findTestabilityInTree(elem); + + if (testability == null) { + throw new Error('Could not find testability for element.'); + } + return new PublicTestability(testability); + }; + window.angular2.resumeBootstrap = function() { + // Intentionally left blank. This will allow Protractor to run + // against angular2 without turning off Angular synchronization. + }; + } +} diff --git a/modules/angular2/src/core/testability/testability.js b/modules/angular2/src/core/testability/testability.js new file mode 100644 index 0000000000..bad334bb27 --- /dev/null +++ b/modules/angular2/src/core/testability/testability.js @@ -0,0 +1,81 @@ +import {DOM} from 'angular2/src/dom/dom_adapter'; +import {Map, MapWrapper, List, ListWrapper} from 'angular2/src/facade/collection'; +import {StringWrapper, isBlank, BaseException} from 'angular2/src/facade/lang'; +import * as getTestabilityModule from 'angular2/src/core/testability/get_testability'; + + +/** + * The Testability service provides testing hooks that can be accessed from + * the browser and by services such as Protractor. Each bootstrapped Angular + * application on the page will have an instance of Testability. + */ +export class Testability { + _pendingCount: number; + _callbacks: List; + + constructor() { + this._pendingCount = 0; + this._callbacks = ListWrapper.create(); + } + + increaseCount(delta: number = 1) { + this._pendingCount += delta; + if (this._pendingCount < 0) { + throw new BaseException('pending async requests below zero'); + } else if (this._pendingCount == 0) { + this._runCallbacks(); + } + return this._pendingCount; + } + + _runCallbacks() { + while (this._callbacks.length !== 0) { + ListWrapper.removeLast(this._callbacks)(); + } + } + + whenStable(callback: Function) { + ListWrapper.push(this._callbacks, callback); + + if (this._pendingCount === 0) { + this._runCallbacks(); + } + // TODO(juliemr) - hook into the zone api. + } + + getPendingCount(): number { + return this._pendingCount; + } + + findBindings(using, binding: string, exactMatch: boolean): List { + // TODO(juliemr): implement. + return []; + } +} + +export class TestabilityRegistry { + _applications: Map; + + constructor() { + this._applications = MapWrapper.create(); + + getTestabilityModule.GetTestability.addToWindow(this); + } + + registerApplication(token, testability: Testability) { + MapWrapper.set(this._applications, token, testability); + } + + findTestabilityInTree(elem) : Testability { + if (elem == null) { + return null; + } + if (MapWrapper.contains(this._applications, elem)) { + return MapWrapper.get(this._applications, elem); + } + if (DOM.isShadowRoot(elem)) { + return this.findTestabilityInTree(DOM.getHost(elem)); + } + return this.findTestabilityInTree(DOM.parentElement(elem)); + } +} diff --git a/modules/angular2/src/dom/browser_adapter.dart b/modules/angular2/src/dom/browser_adapter.dart index 5c812212a4..36e02d214e 100644 --- a/modules/angular2/src/dom/browser_adapter.dart +++ b/modules/angular2/src/dom/browser_adapter.dart @@ -123,6 +123,7 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter { } ShadowRoot createShadowRoot(Element el) => el.createShadowRoot(); ShadowRoot getShadowRoot(Element el) => el.shadowRoot; + Element getHost(Element el) => (el as ShadowRoot).host; clone(Node node) => node.clone(true); bool hasProperty(Element element, String name) => new JsObject.fromBrowserObject(element).hasProperty(name); @@ -188,6 +189,9 @@ class BrowserDomAdapter extends GenericBrowserDomAdapter { bool hasShadowRoot(Node node) { return node is Element && node.shadowRoot != null; } + bool isShadowRoot(Node node) { + return node is ShadowRoot; + } Node importIntoDoc(Node node) { return document.importNode(node, true); } diff --git a/modules/angular2/src/dom/browser_adapter.es6 b/modules/angular2/src/dom/browser_adapter.es6 index 28678d3421..3b389a7ebf 100644 --- a/modules/angular2/src/dom/browser_adapter.es6 +++ b/modules/angular2/src/dom/browser_adapter.es6 @@ -158,6 +158,9 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter { getShadowRoot(el:HTMLElement): ShadowRoot { return el.shadowRoot; } + getHost(el:HTMLElement): HTMLElement { + return el.host; + } clone(node:Node) { return node.cloneNode(true); } @@ -245,6 +248,9 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter { hasShadowRoot(node):boolean { return node instanceof HTMLElement && isPresent(node.shadowRoot); } + isShadowRoot(node):boolean { + return node instanceof ShadowRoot; + } importIntoDoc(node:Node) { var result = document.importNode(node, true); // Workaround WebKit https://bugs.webkit.org/show_bug.cgi?id=137619 diff --git a/modules/angular2/src/dom/dom_adapter.js b/modules/angular2/src/dom/dom_adapter.js index ff18592c29..f799b9a532 100644 --- a/modules/angular2/src/dom/dom_adapter.js +++ b/modules/angular2/src/dom/dom_adapter.js @@ -147,6 +147,9 @@ export class DomAdapter { getShadowRoot(el) { throw _abstract(); } + getHost(el) { + throw _abstract(); + } getDistributedNodes(el) { throw _abstract(); } @@ -231,6 +234,9 @@ export class DomAdapter { hasShadowRoot(node):boolean { throw _abstract(); } + isShadowRoot(node):boolean { + throw _abstract(); + } importIntoDoc(node) { throw _abstract(); } diff --git a/modules/angular2/src/dom/html_adapter.dart b/modules/angular2/src/dom/html_adapter.dart index 85171b2859..0f66f9a184 100644 --- a/modules/angular2/src/dom/html_adapter.dart +++ b/modules/angular2/src/dom/html_adapter.dart @@ -120,6 +120,15 @@ class Html5LibDomAdapter implements DomAdapter { createStyleElement(String css, [doc]) { throw 'not implemented'; } + createShadowRoot(el) { + throw 'not implemented'; + } + getShadowRoot(el) { + throw 'not implemented'; + } + getHost(el) { + throw 'not implemented'; + } clone(node) { throw 'not implemented'; } @@ -199,6 +208,9 @@ class Html5LibDomAdapter implements DomAdapter { bool hasShadowRoot(node) { throw 'not implemented'; } + bool isShadowRoot(node) { + throw 'not implemented'; + } importIntoDoc(node) { throw 'not implemented'; } diff --git a/modules/angular2/src/dom/parse5_adapter.cjs b/modules/angular2/src/dom/parse5_adapter.cjs index aadf27968c..52a28e7f3f 100644 --- a/modules/angular2/src/dom/parse5_adapter.cjs +++ b/modules/angular2/src/dom/parse5_adapter.cjs @@ -252,6 +252,9 @@ export class Parse5DomAdapter extends DomAdapter { getShadowRoot(el) { return el.shadowRoot; } + getHost(el) { + return el.host; + } getDistributedNodes(el) { throw _notImplemented('getDistributedNodes'); } @@ -395,6 +398,9 @@ export class Parse5DomAdapter extends DomAdapter { hasShadowRoot(node):boolean { return isPresent(node.shadowRoot); } + isShadowRoot(node): boolean { + return this.getShadowRoot(node) == node; + } importIntoDoc(node) { return this.clone(node); } diff --git a/modules/angular2/test/core/application_spec.js b/modules/angular2/test/core/application_spec.js index cdf5594611..561eb38a00 100644 --- a/modules/angular2/test/core/application_spec.js +++ b/modules/angular2/test/core/application_spec.js @@ -19,6 +19,7 @@ import {PromiseWrapper} from 'angular2/src/facade/async'; import {bind, Inject} from 'angular2/di'; import {Template} from 'angular2/src/core/annotations/template'; import {LifeCycle} from 'angular2/src/core/life_cycle/life_cycle'; +import {Testability, TestabilityRegistry} from 'angular2/src/core/testability/testability'; @Component({selector: 'hello-app'}) @Template({inline: '{{greeting}} world!'}) @@ -180,5 +181,21 @@ export function main() { async.done(); }); })); + + it('should register each application with the testability registry', inject([AsyncTestCompleter], (async) => { + var injectorPromise1 = bootstrap(HelloRootCmp, testBindings); + var injectorPromise2 = bootstrap(HelloRootCmp2, testBindings); + + PromiseWrapper.all([injectorPromise1, injectorPromise2]).then((injectors) => { + var registry = injectors[0].get(TestabilityRegistry); + PromiseWrapper.all([ + injectors[0].asyncGet(Testability), + injectors[1].asyncGet(Testability)]).then((testabilities) => { + expect(registry.findTestabilityInTree(el)).toEqual(testabilities[0]); + expect(registry.findTestabilityInTree(el2)).toEqual(testabilities[1]); + async.done(); + }); + }); + })); }); } diff --git a/modules/angular2/test/core/testability/testability_spec.js b/modules/angular2/test/core/testability/testability_spec.js new file mode 100644 index 0000000000..ef5ea244bf --- /dev/null +++ b/modules/angular2/test/core/testability/testability_spec.js @@ -0,0 +1,42 @@ +import {describe, ddescribe, it, iit, xit, xdescribe, expect, beforeEach} from 'angular2/test_lib'; +import {Testability} from 'angular2/src/core/testability/testability'; + + +export function main() { + describe('Testability', () => { + var testability, executed; + + beforeEach(() => { + testability = new Testability(); + executed = false; + }); + + it('should start with a pending count of 0', () => { + expect(testability.getPendingCount()).toEqual(0); + }); + + it('should fire whenstable callbacks if pending count is 0', () => { + testability.whenStable(() => executed = true); + expect(executed).toBe(true); + }); + + it('should not call whenstable callbacks when there are pending counts', () => { + testability.increaseCount(2); + testability.whenStable(() => executed = true); + + expect(executed).toBe(false); + testability.increaseCount(-1); + expect(executed).toBe(false); + }); + + it('should fire whenstable callbacks when pending drops to 0', () => { + testability.increaseCount(2); + testability.whenStable(() => executed = true); + + expect(executed).toBe(false); + + testability.increaseCount(-2); + expect(executed).toBe(true); + }); + }); +} diff --git a/modules/benchmarks/src/largetable/largetable_benchmark.js b/modules/benchmarks/src/largetable/largetable_benchmark.js index bab1864ec0..c5db9a5f8f 100644 --- a/modules/benchmarks/src/largetable/largetable_benchmark.js +++ b/modules/benchmarks/src/largetable/largetable_benchmark.js @@ -18,6 +18,7 @@ import {ComponentUrlMapper} from 'angular2/src/core/compiler/component_url_mappe import {StyleInliner} from 'angular2/src/core/compiler/style_inliner'; import {CssProcessor} from 'angular2/src/core/compiler/css_processor'; import {PrivateComponentLoader} from 'angular2/src/core/compiler/private_component_loader'; +import {TestabilityRegistry, Testability} from 'angular2/src/core/testability/testability'; import {reflector} from 'angular2/src/reflection/reflection'; import {DOM} from 'angular2/src/dom/dom_adapter'; @@ -228,6 +229,18 @@ function setupReflector() { "annotations": [] }); + reflector.registerType(TestabilityRegistry, { + "factory": () => new TestabilityRegistry(), + "parameters": [], + "annotations": [] + }); + + reflector.registerType(Testability, { + "factory": () => new Testability(), + "parameters": [], + "annotations": [] + }); + reflector.registerType(Content, { "factory": (lightDom, el) => new Content(lightDom, el), "parameters": [[DestinationLightDom], [NgElement]], diff --git a/modules/benchmarks/src/naive_infinite_scroll/index.js b/modules/benchmarks/src/naive_infinite_scroll/index.js index a0153f2abf..f773635893 100644 --- a/modules/benchmarks/src/naive_infinite_scroll/index.js +++ b/modules/benchmarks/src/naive_infinite_scroll/index.js @@ -25,6 +25,7 @@ import {ComponentUrlMapper} from 'angular2/src/core/compiler/component_url_mappe import {StyleInliner} from 'angular2/src/core/compiler/style_inliner'; import {CssProcessor} from 'angular2/src/core/compiler/css_processor'; import {PrivateComponentLoader} from 'angular2/src/core/compiler/private_component_loader'; +import {TestabilityRegistry, Testability} from 'angular2/src/core/testability/testability'; import {If, For} from 'angular2/directives'; import {App, setupReflectorForApp} from './app'; @@ -294,6 +295,18 @@ export function setupReflectorForAngular() { "annotations" : [new Decorator({selector: '[content]'})] }); + reflector.registerType(TestabilityRegistry, { + "factory": () => new TestabilityRegistry(), + "parameters": [], + "annotations": [] + }); + + reflector.registerType(Testability, { + "factory": () => new Testability(), + "parameters": [], + "annotations": [] + }); + reflector.registerType(StyleInliner, { "factory": (xhr, styleUrlResolver, urlResolver) => new StyleInliner(xhr, styleUrlResolver, urlResolver), diff --git a/modules/benchmarks/src/tree/tree_benchmark.js b/modules/benchmarks/src/tree/tree_benchmark.js index a73b22c905..ee5fc88ea0 100644 --- a/modules/benchmarks/src/tree/tree_benchmark.js +++ b/modules/benchmarks/src/tree/tree_benchmark.js @@ -18,6 +18,7 @@ import {ComponentUrlMapper} from 'angular2/src/core/compiler/component_url_mappe import {StyleInliner} from 'angular2/src/core/compiler/style_inliner'; import {CssProcessor} from 'angular2/src/core/compiler/css_processor'; import {PrivateComponentLoader} from 'angular2/src/core/compiler/private_component_loader'; +import {TestabilityRegistry, Testability} from 'angular2/src/core/testability/testability'; import {reflector} from 'angular2/src/reflection/reflection'; import {DOM} from 'angular2/src/dom/dom_adapter'; @@ -138,6 +139,18 @@ function setupReflector() { "annotations": [] }); + reflector.registerType(TestabilityRegistry, { + "factory": () => new TestabilityRegistry(), + "parameters": [], + "annotations": [] + }); + + reflector.registerType(Testability, { + "factory": () => new Testability(), + "parameters": [], + "annotations": [] + }); + reflector.registerType(StyleUrlResolver, { "factory": (urlResolver) => new StyleUrlResolver(urlResolver), "parameters": [[UrlResolver]], diff --git a/modules/examples/src/hello_world/index_static.js b/modules/examples/src/hello_world/index_static.js index 3f5152feaa..df7e41f159 100644 --- a/modules/examples/src/hello_world/index_static.js +++ b/modules/examples/src/hello_world/index_static.js @@ -21,6 +21,7 @@ import {StyleInliner} from 'angular2/src/core/compiler/style_inliner'; import {CssProcessor} from 'angular2/src/core/compiler/css_processor'; import {EventManager} from 'angular2/src/core/events/event_manager'; import {PrivateComponentLoader} from 'angular2/src/core/compiler/private_component_loader'; +import {TestabilityRegistry, Testability} from 'angular2/src/core/testability/testability'; import {reflector} from 'angular2/src/reflection/reflection'; @@ -185,6 +186,18 @@ function setup() { "annotations": [] }); + reflector.registerType(TestabilityRegistry, { + "factory": () => new TestabilityRegistry(), + "parameters": [], + "annotations": [] + }); + + reflector.registerType(Testability, { + "factory": () => new Testability(), + "parameters": [], + "annotations": [] + }); + reflector.registerGetters({ "greeting": (a) => a.greeting });