diff --git a/karma-js.conf.js b/karma-js.conf.js index 1f9429f186..347b5ab6a4 100644 --- a/karma-js.conf.js +++ b/karma-js.conf.js @@ -38,6 +38,15 @@ module.exports = function(config) { 'test-events.js', 'shims_for_IE.js', 'node_modules/systemjs/dist/system.src.js', + + // Serve polyfills necessary for testing the `elements` package. + { + pattern: 'node_modules/@webcomponents/custom-elements/**/*.js', + included: false, + watched: false + }, + {pattern: 'node_modules/mutation-observer/index.js', included: false, watched: false}, + {pattern: 'node_modules/rxjs/**', included: false, watched: false, served: true}, 'node_modules/reflect-metadata/Reflect.js', 'tools/build/file2modulename.js', diff --git a/package.json b/package.json index 7eb1238574..b8e68d2962 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/selenium-webdriver": "3.0.7", "@types/source-map": "^0.5.1", "@types/systemjs": "0.19.32", + "@webcomponents/custom-elements": "^1.0.4", "angular": "1.5.0", "angular-animate": "1.5.0", "angular-mocks": "1.5.0", @@ -81,6 +82,7 @@ "karma-sourcemap-loader": "0.3.6", "madge": "0.5.0", "minimist": "1.2.0", + "mutation-observer": "^1.0.3", "node-uuid": "1.4.8", "protractor": "5.1.2", "rewire": "2.5.2", diff --git a/packages/elements/public_api.ts b/packages/elements/public_api.ts index 13ea26ff60..e6e2d0fb13 100644 --- a/packages/elements/public_api.ts +++ b/packages/elements/public_api.ts @@ -11,6 +11,7 @@ * @description * Entry point for all public APIs of the `elements` package. */ +export {NgElement, NgElementWithProps} from './src/ng-element'; export {VERSION} from './src/version'; // This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/elements/rollup.config.js b/packages/elements/rollup.config.js index b369a06602..5d18f89d39 100644 --- a/packages/elements/rollup.config.js +++ b/packages/elements/rollup.config.js @@ -11,6 +11,7 @@ const sourcemaps = require('rollup-plugin-sourcemaps'); const globals = { '@angular/core': 'ng.core', + 'rxjs/Subscription': 'Rx', }; module.exports = { diff --git a/packages/elements/src/ng-element.ts b/packages/elements/src/ng-element.ts new file mode 100644 index 0000000000..314791779f --- /dev/null +++ b/packages/elements/src/ng-element.ts @@ -0,0 +1,367 @@ +/** + * @license + * Copyright Google Inc. 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 {ApplicationRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges} from '@angular/core'; +import {Subscription} from 'rxjs/Subscription'; + +import {extractProjectableNodes} from './extract-projectable-nodes'; +import {NgElementApplicationContext} from './ng-element-application-context'; +import {createCustomEvent, getComponentName, isFunction, scheduler, strictEquals, throwError} from './utils'; + +/** + * TODO(gkalpak): Add docs. + * @experimental + */ +export type NgElementWithProps = NgElement& {[property in keyof P]: P[property]}; + +/** + * TODO(gkalpak): Add docs. + * @experimental + */ +export interface NgElement extends HTMLElement { + ngElement: NgElement|null; + componentRef: ComponentRef|null; + + attributeChangedCallback( + attrName: string, oldValue: string|null, newValue: string, namespace?: string): void; + connectedCallback(): void; + detach(): void; + detectChanges(): void; + disconnectedCallback(): void; + getHost(): HTMLElement; + markDirty(): void; +} + +/** + * Represents an `NgElement` input. + * Similar to a `ComponentFactory` input (`{propName: string, templateName: string}`), + * except that `attrName` is derived by kebab-casing `templateName`. + */ +export interface NgElementInput { + propName: string; + attrName: string; +} + +/** + * Represents an `NgElement` input. + * Similar to a `ComponentFactory` output (`{propName: string, templateName: string}`), + * except that `templateName` is renamed to `eventName`. + */ +export interface NgElementOutput { + propName: string; + eventName: string; +} + +/** + * An enum of possible lifecycle phases for `NgElement`s. + */ +const enum NgElementLifecyclePhase { + // The element has been instantiated, but not connected. + // (The associated component has not been created yet.) + unconnected = 'unconnected', + // The element has been instantiated and connected. + // (The associated component has been created.) + connected = 'connected', + // The element has been instantiated, connected and then disconnected. + // (The associated component has been created and then destroyed.) + disconnected = 'disconnected', +} + +interface NgElementConnected extends NgElementImpl { + ngElement: NgElementConnected; + componentRef: ComponentRef; +} + +export abstract class NgElementImpl extends HTMLElement implements NgElement { + private static DESTROY_DELAY = 10; + ngElement: NgElement|null = null; + componentRef: ComponentRef|null = null; + onConnected = new EventEmitter(); + onDisconnected = new EventEmitter(); + + private host = this as HTMLElement; + private readonly componentName = getComponentName(this.componentFactory.componentType); + private readonly initialInputValues = new Map(); + private readonly uninitializedInputs = new Set(); + private readonly outputSubscriptions = new Map(); + private inputChanges: SimpleChanges|null = null; + private implementsOnChanges = false; + private changeDetectionScheduled = false; + private lifecyclePhase: NgElementLifecyclePhase = NgElementLifecyclePhase.unconnected; + private cancelDestruction: (() => void)|null = null; + + constructor( + private appContext: NgElementApplicationContext, + private componentFactory: ComponentFactory, private readonly inputs: NgElementInput[], + private readonly outputs: NgElementOutput[]) { + super(); + } + + attributeChangedCallback( + attrName: string, oldValue: string|null, newValue: string, namespace?: string): void { + const input = this.inputs.find(input => input.attrName === attrName) !; + + if (input) { + this.setInputValue(input.propName, newValue); + } else { + throwError( + `Calling 'attributeChangedCallback()' with unknown attribute '${attrName}' ` + + `on component '${this.componentName}' is not allowed.`); + } + } + + connectedCallback(ignoreUpgraded = false): void { + this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'connectedCallback'); + + if (this.cancelDestruction !== null) { + this.cancelDestruction(); + this.cancelDestruction = null; + } + + if (this.lifecyclePhase === NgElementLifecyclePhase.connected) { + return; + } + + const host = this.host as NgElement; + + if (host.ngElement) { + if (ignoreUpgraded) { + return; + } + + const existingNgElement = (host as NgElementConnected).ngElement; + const existingComponentName = getComponentName(existingNgElement.componentRef.componentType); + + throwError( + `Upgrading '${this.host.nodeName}' element to component '${this.componentName}' is not allowed, ` + + `because the element is already upgraded to component '${existingComponentName}'.`); + } + + this.appContext.runInNgZone(() => { + this.lifecyclePhase = NgElementLifecyclePhase.connected; + const cThis = (this as any as NgElementConnected); + + const childInjector = Injector.create([], cThis.appContext.injector); + const projectableNodes = + extractProjectableNodes(cThis.host, cThis.componentFactory.ngContentSelectors); + cThis.componentRef = + cThis.componentFactory.create(childInjector, projectableNodes, cThis.host); + cThis.implementsOnChanges = + isFunction((cThis.componentRef.instance as any as OnChanges).ngOnChanges); + + cThis.initializeInputs(); + cThis.initializeOutputs(); + cThis.detectChanges(); + + cThis.appContext.applicationRef.attachView(cThis.componentRef.hostView); + + // Ensure `ngElement` is set on the host too (even for manually upgraded elements) + // in order to be able to detect that the element has been been upgraded. + cThis.ngElement = host.ngElement = cThis; + + cThis.onConnected.emit(); + }); + } + + detach(): void { this.disconnectedCallback(); } + + detectChanges(): void { + if (this.lifecyclePhase === NgElementLifecyclePhase.disconnected) { + return; + } + + this.assertNotInPhase(NgElementLifecyclePhase.unconnected, 'detectChanges'); + + this.appContext.runInNgZone(() => { + const cThis = this as any as NgElementConnected; + + cThis.changeDetectionScheduled = false; + + cThis.callNgOnChanges(); + cThis.componentRef.changeDetectorRef.detectChanges(); + }); + } + + disconnectedCallback(): void { + if (this.lifecyclePhase === NgElementLifecyclePhase.disconnected || + this.cancelDestruction !== null) { + return; + } + + this.assertNotInPhase(NgElementLifecyclePhase.unconnected, 'disconnectedCallback'); + + const doDestroy = () => this.appContext.runInNgZone(() => this.destroy()); + this.cancelDestruction = scheduler.schedule(doDestroy, NgElementImpl.DESTROY_DELAY); + } + + getHost(): HTMLElement { return this.host; } + + getInputValue(propName: string): any { + this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'getInputValue'); + + if (this.lifecyclePhase === NgElementLifecyclePhase.unconnected) { + return this.initialInputValues.get(propName); + } + + const cThis = this as any as NgElementConnected; + + return (cThis.componentRef.instance as any)[propName]; + } + + markDirty(): void { + if (!this.changeDetectionScheduled) { + this.changeDetectionScheduled = true; + scheduler.scheduleBeforeRender(() => this.detectChanges()); + } + } + + setHost(host: HTMLElement): void { + this.assertNotInPhase(NgElementLifecyclePhase.connected, 'setHost'); + this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'setHost'); + + this.host = host; + } + + setInputValue(propName: string, newValue: any): void { + this.assertNotInPhase(NgElementLifecyclePhase.disconnected, 'setInputValue'); + + if (this.lifecyclePhase === NgElementLifecyclePhase.unconnected) { + this.initialInputValues.set(propName, newValue); + return; + } + + const cThis = this as any as NgElementConnected; + + if (!strictEquals(newValue, cThis.getInputValue(propName))) { + cThis.recordInputChange(propName, newValue); + (cThis.componentRef.instance as any)[propName] = newValue; + cThis.markDirty(); + } + } + + private assertNotInPhase(phase: NgElementLifecyclePhase, caller: keyof this): void { + if (this.lifecyclePhase === phase) { + throwError( + `Calling '${caller}()' on ${phase} component '${this.componentName}' is not allowed.`); + } + } + + private callNgOnChanges(this: NgElementConnected): void { + if (this.implementsOnChanges && this.inputChanges !== null) { + const inputChanges = this.inputChanges; + this.inputChanges = null; + (this.componentRef.instance as any as OnChanges).ngOnChanges(inputChanges); + } + } + + private destroy() { + const cThis = this as any as NgElementConnected; + + cThis.componentRef.destroy(); + cThis.outputs.forEach(output => cThis.unsubscribeFromOutput(output)); + + this.ngElement = (this.host as NgElement).ngElement = null; + cThis.host.innerHTML = ''; + + cThis.lifecyclePhase = NgElementLifecyclePhase.disconnected; + cThis.onDisconnected.emit(); + } + + private dispatchCustomEvent(eventName: string, value: any): void { + const event = createCustomEvent(this.host.ownerDocument, eventName, value); + + this.dispatchEvent(event); + + if (this.host !== this) { + this.host.dispatchEvent(event); + } + } + + private initializeInputs(): void { + this.inputs.forEach(({propName, attrName}) => { + let initialValue; + + if (this.initialInputValues.has(propName)) { + // The property has already been set (prior to initialization). + // Update the component instance. + initialValue = this.initialInputValues.get(propName); + } else if (this.host.hasAttribute(attrName)) { + // A matching attribute exists. + // Update the component instance. + initialValue = this.host.getAttribute(attrName); + } else { + // The property does not have an initial value. + this.uninitializedInputs.add(propName); + } + + if (!this.uninitializedInputs.has(propName)) { + // The property does have an initial value. + // Forward it to the component instance. + this.setInputValue(propName, initialValue); + } + }); + + this.initialInputValues.clear(); + } + + private initializeOutputs(this: NgElementConnected): void { + this.outputs.forEach(output => this.subscribeToOutput(output)); + } + + private recordInputChange(propName: string, currentValue: any): void { + if (!this.implementsOnChanges) { + // The component does not implement `OnChanges`. Ignore the change. + return; + } + + if (this.inputChanges === null) { + this.inputChanges = {}; + } + + const pendingChange = this.inputChanges[propName]; + + if (pendingChange) { + pendingChange.currentValue = currentValue; + return; + } + + const isFirstChange = this.uninitializedInputs.has(propName); + const previousValue = isFirstChange ? undefined : this.getInputValue(propName); + this.inputChanges[propName] = new SimpleChange(previousValue, currentValue, isFirstChange); + + if (isFirstChange) { + this.uninitializedInputs.delete(propName); + } + } + + private subscribeToOutput(this: NgElementConnected, output: NgElementOutput): void { + const {propName, eventName} = output; + const emitter = (this.componentRef.instance as any)[output.propName] as EventEmitter; + + if (!emitter) { + throwError(`Missing emitter '${propName}' on component '${this.componentName}'.`); + } + + this.unsubscribeFromOutput(output); + + const subscription = + emitter.subscribe((value: any) => this.dispatchCustomEvent(eventName, value)); + this.outputSubscriptions.set(propName, subscription); + } + + private unsubscribeFromOutput({propName}: NgElementOutput): void { + if (!this.outputSubscriptions.has(propName)) { + return; + } + + const subscription = this.outputSubscriptions.get(propName) !; + + this.outputSubscriptions.delete(propName); + subscription.unsubscribe(); + } +} diff --git a/packages/elements/test/ng-element_spec.ts b/packages/elements/test/ng-element_spec.ts new file mode 100644 index 0000000000..040b491061 --- /dev/null +++ b/packages/elements/test/ng-element_spec.ts @@ -0,0 +1,1232 @@ +/** + * @license + * Copyright Google Inc. 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 {AfterContentChecked, AfterContentInit, AfterViewChecked, AfterViewInit, ApplicationRef, Component, ComponentFactory, DoCheck, EventEmitter, Inject, Injector, Input, NgModule, NgModuleRef, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChange, SimpleChanges, destroyPlatform} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {NgElementImpl, NgElementInput, NgElementOutput} from '../src/ng-element'; +import {NgElementApplicationContext} from '../src/ng-element-application-context'; +import {scheduler} from '../src/utils'; +import {AsyncMockScheduler, installMockScheduler, patchEnv, restoreEnv, supportsCustomElements} from '../testing/index'; + +type WithFooBar = { + foo: string, + bar: string +}; + +export function main() { + if (!supportsCustomElements()) { + return; + } + + describe('NgElement', () => { + const DESTROY_DELAY = 10; + const disconnectSync = (elem: NgElementImpl) => { + elem.disconnectedCallback(); + mockScheduler.tick(DESTROY_DELAY); + }; + + let mockScheduler: AsyncMockScheduler; + let moduleRef: NgModuleRef; + let nodeName: string; + let e: NgElementImpl&WithFooBar; + let host: HTMLElement; + + beforeAll(() => patchEnv()); + afterAll(() => restoreEnv()); + + [true, false].forEach(instantiateDirectly => { + [true, false].forEach(useItselfAsHost => { + const methodStr = instantiateDirectly ? 'directly' : 'with `document.createElement()`'; + const hostStr = useItselfAsHost ? 'itself' : 'another element'; + const description = `(instantiated ${methodStr} with ${hostStr} as host)`; + + describe(description, () => { + beforeEach(done => { + mockScheduler = installMockScheduler(); + + destroyPlatform(); + platformBrowserDynamic() + .bootstrapModule(TestModule) + .then(ref => { + moduleRef = ref; + nodeName = useItselfAsHost ? 'TEST-COMPONENT-FOR-NGE' : 'TEST-HOST'; + e = instantiateDirectly ? new TestNgElement() : + document.createElement(TestNgElement.is) as any; + + if (!useItselfAsHost) { + e.setHost(document.createElement('test-host')); + } + + host = e.getHost(); + }) + .then(done, done.fail); + }); + + afterEach(() => destroyPlatform()); + + it('should be an HTMLElement', () => { expect(e).toEqual(jasmine.any(HTMLElement)); }); + + it(`should have ${useItselfAsHost ? 'itself' : 'another element'} as host`, + () => { expect(host.nodeName).toBe(nodeName); }); + + describe('attributeChangedCallback()', () => { + let markDirtySpy: jasmine.Spy; + + beforeEach(() => markDirtySpy = spyOn(e, 'markDirty')); + + it('should update the corresponding property when unconnected', () => { + expect(e.foo).toBeUndefined(); + expect(e.bar).toBeUndefined(); + + e.attributeChangedCallback('foo', null, 'newFoo'); + e.attributeChangedCallback('b-a-r', null, 'newBar'); + + expect(e.foo).toBe('newFoo'); + expect(e.bar).toBe('newBar'); + + expect(markDirtySpy).not.toHaveBeenCalled(); + }); + + it('should update the corresponding property (when connected)', () => { + e.connectedCallback(); + + expect(e.foo).toBe('foo'); + expect(e.bar).toBeUndefined(); + + e.attributeChangedCallback('foo', null, 'newFoo'); + e.attributeChangedCallback('b-a-r', null, 'newBar'); + + expect(e.foo).toBe('newFoo'); + expect(e.bar).toBe('newBar'); + + expect(markDirtySpy).toHaveBeenCalledTimes(2); + }); + + it('should update the component instance (when connected)', () => { + e.connectedCallback(); + const component = e.componentRef !.instance; + + expect(component.foo).toBe('foo'); + expect(component.bar).toBeUndefined(); + + e.attributeChangedCallback('foo', null, 'newFoo'); + e.attributeChangedCallback('b-a-r', null, 'newBar'); + + expect(component.foo).toBe('newFoo'); + expect(component.bar).toBe('newBar'); + + expect(markDirtySpy).toHaveBeenCalledTimes(2); + }); + + it('should mark as dirty (when connected)', () => { + e.connectedCallback(); + e.attributeChangedCallback('foo', null, 'newFoo'); + e.attributeChangedCallback('b-a-r', null, 'newBar'); + + expect(markDirtySpy).toHaveBeenCalledTimes(2); + }); + + it('should not mark as dirty if the new value equals the old one', () => { + e.connectedCallback(); + + e.attributeChangedCallback('foo', null, 'newFoo'); + e.attributeChangedCallback('b-a-r', null, 'newBar'); + markDirtySpy.calls.reset(); + + e.attributeChangedCallback('foo', null, 'newFoo'); + e.attributeChangedCallback('b-a-r', null, 'newBar'); + expect(markDirtySpy).not.toHaveBeenCalled(); + }); + + it('should throw when disconnected', () => { + const errorMessage = + 'Calling \'setInputValue()\' on disconnected component \'TestComponent\' is not allowed.'; + + e.connectedCallback(); + disconnectSync(e); + + const fn = () => e.attributeChangedCallback('foo', null, 'newFoo'); + + expect(fn).toThrowError(errorMessage); + expect(markDirtySpy).not.toHaveBeenCalled(); + }); + + it('should throw when called with unknown attribute', () => { + const fn = () => e.attributeChangedCallback('unknown', null, 'newUnknown'); + const errorMessage = + 'Calling \'attributeChangedCallback()\' with unknown attribute \'unknown\' ' + + 'on component \'TestComponent\' is not allowed.'; + + expect(fn).toThrowError(errorMessage); + expect((e as any).unknown).toBeUndefined(); + expect(markDirtySpy).not.toHaveBeenCalled(); + + e.connectedCallback(); + + expect(fn).toThrowError(errorMessage); + expect((e as any).unknown).toBeUndefined(); + expect(markDirtySpy).not.toHaveBeenCalled(); + + e.disconnectedCallback(); + + expect(fn).toThrowError(errorMessage); + expect((e as any).unknown).toBeUndefined(); + expect(markDirtySpy).not.toHaveBeenCalled(); + }); + }); + + describe('connectedCallback()', () => { + let detectChangesSpy: jasmine.Spy; + + beforeEach(() => detectChangesSpy = spyOn(e, 'detectChanges').and.callThrough()); + + it('should create the component', () => { + expect(e.componentRef).toBeNull(); + + e.connectedCallback(); + + const componentRef = e.componentRef !; + expect(componentRef).not.toBeNull(); + expect(componentRef.instance).toEqual(jasmine.any(TestComponent)); + expect(host.textContent).toContain('TestComponent'); + }); + + it('should instantiate the component inside the Angular zone', () => { + e.connectedCallback(); + + expect(NgZone.isInAngularZone()).toBe(false); + expect(e.componentRef !.instance.createdInNgZone).toBe(true); + }); + + it('should use the provided injector as parent', () => { + e.connectedCallback(); + + expect(e.componentRef !.instance.testValue).toBe('TEST'); + expect(e.componentRef !.injector).not.toBe(moduleRef.injector); + }); + + it('should project any content', () => { + host.innerHTML = 'rest-1' + + 'baz-1' + + 'rest-2' + + '
baz-2
'; + + e.connectedCallback(); + + expect(host.textContent) + .toBe('TestComponent|foo(foo)|bar()|baz(baz-1baz-2)|rest(rest-1rest-2)'); + }); + + it('should initialize component inputs with already set property values', () => { + e.foo = 'newFoo'; + e.bar = 'newBar'; + e.connectedCallback(); + + expect(e.componentRef !.instance.foo).toBe('newFoo'); + expect(e.componentRef !.instance.bar).toBe('newBar'); + }); + + it('should use the most recent property value', () => { + e.foo = 'newFoo'; + e.foo = 'newerFoo'; + e.foo = 'newestFoo'; + e.connectedCallback(); + + expect(e.componentRef !.instance.foo).toBe('newestFoo'); + }); + + it('should initialize component inputs from attributes (if properties are not set)', + () => { + host.setAttribute('foo', 'newFoo'); + host.setAttribute('b-a-r', 'newBar'); + host.setAttribute('bar', 'ignored'); + host.setAttribute('bA-r', 'ignored'); + e.connectedCallback(); + + expect(e.componentRef !.instance.foo).toBe('newFoo'); + expect(e.componentRef !.instance.bar).toBe('newBar'); + }); + + it('should prioritize properties over attributes (if both have been set)', () => { + host.setAttribute('foo', 'newFoo'); + host.setAttribute('b-a-r', 'newBar'); + e.bar = 'newerBar'; + e.connectedCallback(); + + expect(e.componentRef !.instance.foo).toBe('newFoo'); + expect(e.componentRef !.instance.bar).toBe('newerBar'); + }); + + it('should not ignore undefined as an input value', () => { + host.setAttribute('foo', 'newFoo'); + e.foo = 'newerFoo'; + e.foo = undefined as any; + e.connectedCallback(); + + expect(e.componentRef !.instance.foo).toBeUndefined(); + }); + + it('should convert component output emissions to custom events', () => { + const listeners = { + bazOnNgElement: jasmine.createSpy('bazOnNgElement'), + BAZOnNgElement: jasmine.createSpy('BAZOnNgElement'), + quxOnNgElement: jasmine.createSpy('quxOnNgElement'), + 'q-u-xOnNgElement': jasmine.createSpy('q-u-xOnNgElement'), + bazOnHost: jasmine.createSpy('bazOnHost'), + BAZOnHost: jasmine.createSpy('BAZOnHost'), + quxOnHost: jasmine.createSpy('quxOnHost'), + 'q-u-xOnHost': jasmine.createSpy('q-u-xOnHost'), + }; + + // Only events `baz` and `q-u-x` exist (regardless of pre-/post-connected phase). + e.addEventListener('baz', listeners.bazOnNgElement); + e.addEventListener('BAZ', listeners.BAZOnNgElement); + host.addEventListener('qux', listeners.quxOnHost); + host.addEventListener('q-u-x', listeners['q-u-xOnHost']); + e.connectedCallback(); + host.addEventListener('baz', listeners.bazOnHost); + host.addEventListener('BAZ', listeners.BAZOnHost); + e.addEventListener('qux', listeners.quxOnNgElement); + e.addEventListener('q-u-x', listeners['q-u-xOnNgElement']); + + Object.keys(listeners).forEach( + (k: keyof typeof listeners) => expect(listeners[k]).not.toHaveBeenCalled()); + + e.componentRef !.instance.baz.emit(false); + e.componentRef !.instance.qux.emit({qux: true}); + + ['BAZOnNgElement', 'BAZOnHost', 'quxOnNgElement', 'quxOnHost'].forEach( + (k: keyof typeof listeners) => expect(listeners[k]).not.toHaveBeenCalled()); + + expect(listeners.bazOnNgElement).toHaveBeenCalledTimes(1); + expect(listeners.bazOnNgElement).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'baz', + detail: false, + })); + expect(listeners.bazOnHost).toHaveBeenCalledTimes(1); + expect(listeners.bazOnHost).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'baz', + detail: false, + })); + + expect(listeners['q-u-xOnNgElement']).toHaveBeenCalledTimes(1); + expect(listeners['q-u-xOnNgElement']).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'q-u-x', + detail: {qux: true}, + })); + expect(listeners['q-u-xOnHost']).toHaveBeenCalledTimes(1); + expect(listeners['q-u-xOnHost']).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'q-u-x', + detail: {qux: true}, + })); + }); + + it('should run output event listeners outside the Angular zone', () => { + const expectOutsideNgZone = () => expect(NgZone.isInAngularZone()).toBe(false); + const listeners = { + bazOnNgElement: + jasmine.createSpy('bazOnNgElement').and.callFake(expectOutsideNgZone), + bazOnHost: jasmine.createSpy('bazOnHost').and.callFake(expectOutsideNgZone), + }; + + e.addEventListener('baz', listeners.bazOnNgElement); + e.connectedCallback(); + host.addEventListener('baz', listeners.bazOnHost); + + const ngZone = moduleRef.injector.get(NgZone); + ngZone.run(() => e.componentRef !.instance.baz.emit(true)); + + expect(listeners.bazOnNgElement).toHaveBeenCalledTimes(1); + expect(listeners.bazOnHost).toHaveBeenCalledTimes(1); + }); + + it('should trigger change detection', () => { + expect(detectChangesSpy).not.toHaveBeenCalled(); + + e.connectedCallback(); + expect(detectChangesSpy).toHaveBeenCalledWith(); + }); + + it('should wire up the component for change detection', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + const expectedContent1 = 'TestComponent|foo(foo)|bar()|baz()|rest()'; + const expectedContent2 = 'TestComponent|foo(foo)|bar(newBar)|baz()|rest()'; + + e.connectedCallback(); + const detectChangesSpy = + spyOn(e.componentRef !.changeDetectorRef, 'detectChanges').and.callThrough(); + + expect(host.textContent).toBe(expectedContent1); + + e.componentRef !.instance.bar = 'newBar'; + appRef.tick(); + + expect(detectChangesSpy).toHaveBeenCalledWith(); + expect(host.textContent).toBe(expectedContent2); + }); + + it('should set `ngElement` on both itself and the host (if not the same)', () => { + expect(e.ngElement).toBeFalsy(); + expect((host as typeof e).ngElement).toBeFalsy(); + + e.connectedCallback(); + + expect(e.ngElement).toBe(e); + expect((host as typeof e).ngElement).toBe(e); + }); + + it('should emit an `onConnected` event', () => { + const onConnectedSpy = jasmine.createSpy('onConnectedSpy'); + + e.onConnected.subscribe(onConnectedSpy); + e.connectedCallback(); + + expect(onConnectedSpy).toHaveBeenCalledTimes(1); + }); + + it('should throw if the host is already upgraded (ignoreUpgraded: false)', () => { + (host as typeof e).ngElement = { + componentRef: {componentType: class FooComponent{}} + } as any; + const errorMessage = + `Upgrading '${nodeName}' element to component 'TestComponent' is not allowed, ` + + 'because the element is already upgraded to component \'FooComponent\'.'; + + expect(() => e.connectedCallback()).toThrowError(errorMessage); + expect(e.componentRef).toBeNull(); + + expect(() => e.connectedCallback(false)).toThrowError(errorMessage); + expect(e.componentRef).toBeNull(); + + expect(detectChangesSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing if the host is already upgraded (ignoreUpgraded: true)', () => { + (host as typeof e).ngElement = {} as any; + + expect(() => e.connectedCallback(true)).not.toThrow(); + expect(e.componentRef).toBeNull(); + + expect(detectChangesSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing if already connected', () => { + e.connectedCallback(); + + const componentRef = e.componentRef; + detectChangesSpy.calls.reset(); + + e.connectedCallback(); + + expect(e.componentRef).toBe(componentRef); + expect(detectChangesSpy).not.toHaveBeenCalled(); + }); + + it('should cancel a scheduled destruction (and do nothing)', () => { + const onDisconnectedSpy = jasmine.createSpy('onDisconnected'); + + e.onDisconnected.subscribe(onDisconnectedSpy); + e.connectedCallback(); + e.disconnectedCallback(); + e.disconnectedCallback(); + + const componentRef = e.componentRef; + detectChangesSpy.calls.reset(); + + mockScheduler.tick(DESTROY_DELAY - 1); + e.connectedCallback(); + mockScheduler.tick(DESTROY_DELAY); + + expect(e.componentRef).toBe(componentRef); + expect(detectChangesSpy).not.toHaveBeenCalled(); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + }); + + it('should throw when disconnected', () => { + const errorMessage = + 'Calling \'connectedCallback()\' on disconnected component \'TestComponent\' is not allowed.'; + + e.connectedCallback(); + disconnectSync(e); + detectChangesSpy.calls.reset(); + + expect(() => e.connectedCallback()).toThrowError(errorMessage); + expect(e.ngElement).toBeNull(); + expect(detectChangesSpy).not.toHaveBeenCalled(); + }); + }); + + describe('detach()', () => { + it('should delegate to `disconnectedCallback()`', () => { + const disconnectedCallbackSpy = spyOn(e, 'disconnectedCallback'); + + expect(disconnectedCallbackSpy).not.toHaveBeenCalled(); + + e.detach(); + expect(disconnectedCallbackSpy).toHaveBeenCalledWith(); + }); + }); + + describe('detectChanges()', () => { + it('should throw when unconnected', () => { + const errorMessage = + 'Calling \'detectChanges()\' on unconnected component \'TestComponent\' is not allowed.'; + expect(() => e.detectChanges()).toThrowError(errorMessage); + }); + + it('should allow scheduling more change detection', () => { + e.connectedCallback(); + + const detectChangesSpy = spyOn(e, 'detectChanges').and.callThrough(); + + e.markDirty(); + e.markDirty(); + mockScheduler.flushBeforeRender(); + + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + + detectChangesSpy.calls.reset(); + e.markDirty(); + e.detectChanges(); + e.markDirty(); + mockScheduler.flushBeforeRender(); + + expect(detectChangesSpy).toHaveBeenCalledTimes(3); + }); + + it('should call `ngOnChanges()` (if implemented and there are changes)', () => { + e.connectedCallback(); + + const compRef = e.componentRef !; + const ngOnChangesSpy = spyOn(compRef.instance, 'ngOnChanges'); + + e.foo = 'newFoo'; + e.detectChanges(); + + expect(ngOnChangesSpy).toHaveBeenCalledTimes(1); + }); + + it('should call `ngOnChanges()` inside the Angular zone', () => { + e.connectedCallback(); + + const compRef = e.componentRef !; + const ngOnChangesSpy = + spyOn(compRef.instance, 'ngOnChanges') + .and.callFake(() => expect(NgZone.isInAngularZone()).toBe(true)); + + e.foo = 'newFoo'; + e.detectChanges(); + + expect(NgZone.isInAngularZone()).toBe(false); + expect(ngOnChangesSpy).toHaveBeenCalledTimes(1); + }); + + it('should not call `ngOnChanges()` if the component does not implement it', () => { + const ngOnChanges = TestComponent.prototype.ngOnChanges; + + try { + TestComponent.prototype.ngOnChanges = null as any; + e.connectedCallback(); + } finally { + TestComponent.prototype.ngOnChanges = ngOnChanges; + } + + const compRef = e.componentRef !; + const ngOnChangesSpy = spyOn(compRef.instance, 'ngOnChanges'); + + e.foo = 'newFoo'; + e.detectChanges(); + + expect(ngOnChangesSpy).not.toHaveBeenCalled(); + }); + + it('should not call `ngOnChanges()` if there are no changes', () => { + e.connectedCallback(); + + const compRef = e.componentRef !; + const ngOnChangesSpy = spyOn(compRef.instance, 'ngOnChanges'); + + e.detectChanges(); + + expect(ngOnChangesSpy).not.toHaveBeenCalled(); + }); + + it('should reset the "pending changes" flag', () => { + e.connectedCallback(); + + const compRef = e.componentRef !; + const ngOnChangesSpy = spyOn(compRef.instance, 'ngOnChanges'); + + e.foo = 'newFoo'; + e.detectChanges(); + + expect(ngOnChangesSpy).toHaveBeenCalledTimes(1); + + ngOnChangesSpy.calls.reset(); + e.detectChanges(); + + expect(ngOnChangesSpy).not.toHaveBeenCalled(); + }); + + it('should call `detectChanges()` on the component (after `ngOnChanges()`)', () => { + e.connectedCallback(); + + const compRef = e.componentRef !; + const ngOnChangesSpy = + spyOn(compRef.instance, 'ngOnChanges') + .and.callFake(() => expect(cdDetectChangesSpy).not.toHaveBeenCalled()); + const cdDetectChangesSpy = + spyOn(compRef.changeDetectorRef, 'detectChanges').and.callThrough(); + + e.foo = 'newFoo'; + e.detectChanges(); + + expect(ngOnChangesSpy).toHaveBeenCalledTimes(1); + expect(cdDetectChangesSpy).toHaveBeenCalledWith(); + }); + + it('should call `detectChanges()` inside the Angular zone', () => { + e.connectedCallback(); + + const compRef = e.componentRef !; + const originalCdDetectChanges = compRef.changeDetectorRef.detectChanges; + const cdDetectChangesSpy = + spyOn(compRef.changeDetectorRef, 'detectChanges').and.callFake(() => { + expect(NgZone.isInAngularZone()).toBe(true); + originalCdDetectChanges.call(compRef.changeDetectorRef); + }); + + e.foo = 'newFoo'; + e.detectChanges(); + + expect(NgZone.isInAngularZone()).toBe(false); + expect(cdDetectChangesSpy).toHaveBeenCalledWith(); + }); + + it('should do nothing when disconnected', () => { + e.connectedCallback(); + disconnectSync(e); + + const cdDetectChangesSpy = spyOn(e.componentRef !.changeDetectorRef, 'detectChanges'); + + e.detectChanges(); + + expect(cdDetectChangesSpy).not.toHaveBeenCalled(); + }); + }); + + describe('disconnectedCallback()', () => { + it('should throw when unconnected', () => { + const errorMessage = + 'Calling \'disconnectedCallback()\' on unconnected component \'TestComponent\' ' + + 'is not allowed.'; + + expect(() => disconnectSync(e)).toThrowError(errorMessage); + }); + + it('should not be immediately disconnected', () => { + const errorMessage = + 'Calling \'setInputValue()\' on disconnected component \'TestComponent\' is not allowed.'; + + e.connectedCallback(); + e.disconnectedCallback(); + + expect(() => e.foo = 'newFoo').not.toThrow(); + expect(e.componentRef !.instance.foo).toBe('newFoo'); + + mockScheduler.tick(DESTROY_DELAY); + + expect(() => e.foo = 'newerFoo').toThrowError(errorMessage); + }); + + it('should do nothing when already disconnected', () => { + e.connectedCallback(); + disconnectSync(e); + + const destroySpy = spyOn(e.componentRef !, 'destroy'); + const onDisconnectedSpy = jasmine.createSpy('onDisconnectedSpy'); + e.onDisconnected.subscribe(onDisconnectedSpy); + + disconnectSync(e); + + expect(destroySpy).not.toHaveBeenCalled(); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing when already scheduled for destruction', () => { + e.connectedCallback(); + e.disconnectedCallback(); + + const destroySpy = spyOn(e.componentRef !, 'destroy'); + const onDisconnectedSpy = jasmine.createSpy('onDisconnectedSpy'); + e.onDisconnected.subscribe(onDisconnectedSpy); + + mockScheduler.reset(); + disconnectSync(e); + disconnectSync(e); + + expect(destroySpy).not.toHaveBeenCalled(); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + }); + + describe('after some delay', () => { + it('should destroy the component', () => { + e.connectedCallback(); + const destroySpy = spyOn(e.componentRef !, 'destroy'); + + e.disconnectedCallback(); + expect(destroySpy).not.toHaveBeenCalled(); + + mockScheduler.tick(DESTROY_DELAY - 1); + expect(destroySpy).not.toHaveBeenCalled(); + + mockScheduler.tick(1); + expect(destroySpy).toHaveBeenCalledWith(); + }); + + it('should destroy the component inside the Angular zone', () => { + e.connectedCallback(); + const destroySpy = + spyOn(e.componentRef !, 'destroy') + .and.callFake(() => expect(NgZone.isInAngularZone()).toBe(true)); + + disconnectSync(e); + + expect(NgZone.isInAngularZone()).toBe(false); + expect(destroySpy).toHaveBeenCalledWith(); + }); + + it('should stop converting component output emissions to custom events', () => { + const listenerForConnected = jasmine.createSpy('listenerForConnected'); + const listenerForDisconnected = jasmine.createSpy('listenerForDisconnected'); + + e.connectedCallback(); + + const component = e.componentRef !.instance; + const emit = () => { + component.baz.emit(false); + component.qux.emit({qux: true}); + }; + + e.addEventListener('baz', listenerForConnected); + e.addEventListener('q-u-x', listenerForConnected); + host.addEventListener('baz', listenerForConnected); + host.addEventListener('q-u-x', listenerForConnected); + emit(); + + expect(listenerForConnected).toHaveBeenCalledTimes(useItselfAsHost ? 2 : 4); + + listenerForConnected.calls.reset(); + disconnectSync(e); + + e.addEventListener('baz', listenerForDisconnected); + e.addEventListener('q-u-x', listenerForDisconnected); + host.addEventListener('baz', listenerForDisconnected); + host.addEventListener('q-u-x', listenerForDisconnected); + emit(); + + expect(listenerForConnected).not.toHaveBeenCalled(); + expect(listenerForDisconnected).not.toHaveBeenCalled(); + }); + + it('should unset `ngElement` on both itself and the host (if not the same)', () => { + e.connectedCallback(); + disconnectSync(e); + + expect(e.ngElement).toBeNull(); + expect((host as typeof e).ngElement).toBeNull(); + }); + + it('should empty the host', () => { + host.innerHTML = 'not empty'; + + e.connectedCallback(); + disconnectSync(e); + + expect(host.innerHTML).toBe(''); + }); + + it('should emit an `onDisconnected` event', () => { + const onDisconnectedSpy = jasmine.createSpy('onDisconnectedSpy'); + e.onDisconnected.subscribe(onDisconnectedSpy); + + e.connectedCallback(); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + + e.disconnectedCallback(); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + + mockScheduler.tick(DESTROY_DELAY - 1); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + + mockScheduler.tick(1); + expect(onDisconnectedSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('getHost()', () => { + it('should return the current host (regardless of the lifecycle phase)', () => { + expect(e.getHost()).toBe(host); + + const newHost = document.createElement('new-test-host'); + + e.setHost(newHost); + expect(e.getHost()).toBe(newHost); + + e.connectedCallback(); + expect(e.getHost()).toBe(newHost); + + e.disconnectedCallback(); + expect(e.getHost()).toBe(newHost); + }); + }); + + describe('getInputValue()', () => { + it('should return the corresponding property when unconnected', () => { + expect(e.getInputValue('foo')).toBeUndefined(); + expect(e.getInputValue('bar')).toBeUndefined(); + + e.foo = 'newFoo'; + e.bar = 'newBar'; + + expect(e.getInputValue('foo')).toBe('newFoo'); + expect(e.getInputValue('bar')).toBe('newBar'); + }); + + it('should return the corresponding component property (when connected)', () => { + e.connectedCallback(); + const component = e.componentRef !.instance; + + expect(e.getInputValue('foo')).toBe('foo'); + expect(e.getInputValue('bar')).toBeUndefined(); + + e.foo = 'newFoo'; + e.bar = 'newBar'; + + expect(e.getInputValue('foo')).toBe('newFoo'); + expect(e.getInputValue('bar')).toBe('newBar'); + + component.foo = 'newerFoo'; + component.bar = 'newerBar'; + + expect(e.getInputValue('foo')).toBe('newerFoo'); + expect(e.getInputValue('bar')).toBe('newerBar'); + }); + + it('should throw when disconnected', () => { + const errorMessage = + 'Calling \'getInputValue()\' on disconnected component \'TestComponent\' is not allowed.'; + + e.connectedCallback(); + disconnectSync(e); + + expect(() => e.getInputValue('foo')).toThrowError(errorMessage); + }); + }); + + describe('markDirty()', () => { + let detectChangesSpy: jasmine.Spy; + + beforeEach(() => { + e.connectedCallback(); + detectChangesSpy = spyOn(e, 'detectChanges'); + }); + + it('should schedule change detection', () => { + e.markDirty(); + expect(detectChangesSpy).not.toHaveBeenCalled(); + + mockScheduler.flushBeforeRender(); + expect(detectChangesSpy).toHaveBeenCalledWith(); + }); + + it('should not schedule change detection if already scheduled', () => { + e.markDirty(); + e.markDirty(); + e.markDirty(); + mockScheduler.flushBeforeRender(); + + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('setHost()', () => { + it('should set the host (when unconnected)', () => { + const newHost = document.createElement('new-test-host'); + e.setHost(newHost); + expect(e.getHost()).toBe(newHost); + }); + + it('should throw when connected', () => { + const errorMessage = + 'Calling \'setHost()\' on connected component \'TestComponent\' is not allowed.'; + + e.connectedCallback(); + + expect(() => e.setHost({} as any)).toThrowError(errorMessage); + expect(e.getHost()).toBe(host); + }); + + it('should throw when disconnected', () => { + const errorMessage = + 'Calling \'setHost()\' on disconnected component \'TestComponent\' is not allowed.'; + + e.connectedCallback(); + disconnectSync(e); + + expect(() => e.setHost({} as any)).toThrowError(errorMessage); + expect(e.getHost()).toBe(host); + }); + }); + + describe('setInputValue()', () => { + let markDirtySpy: jasmine.Spy; + + beforeEach(() => markDirtySpy = spyOn(e, 'markDirty')); + + it('should update the corresponding property when unconnected', () => { + expect(e.foo).toBeUndefined(); + expect(e.bar).toBeUndefined(); + + e.setInputValue('foo', 'newFoo'); + e.setInputValue('bar', 'newBar'); + + expect(e.foo).toBe('newFoo'); + expect(e.bar).toBe('newBar'); + + expect(markDirtySpy).not.toHaveBeenCalled(); + }); + + it('should update the corresponding component property (when connected)', () => { + e.connectedCallback(); + const component = e.componentRef !.instance; + + expect(component.foo).toBe('foo'); + expect(component.bar).toBeUndefined(); + + e.setInputValue('foo', 'newFoo'); + e.setInputValue('bar', 'newBar'); + + expect(component.foo).toBe('newFoo'); + expect(component.bar).toBe('newBar'); + }); + + it('should mark as dirty (when connected)', () => { + e.connectedCallback(); + + e.setInputValue('foo', 'newFoo'); + expect(markDirtySpy).toHaveBeenCalledTimes(1); + + e.setInputValue('bar', 'newBar'); + expect(markDirtySpy).toHaveBeenCalledTimes(2); + }); + + it('should not mark as dirty if the new value equals the old one', () => { + e.connectedCallback(); + + e.setInputValue('foo', 'newFoo'); + expect(markDirtySpy).toHaveBeenCalledTimes(1); + + e.setInputValue('foo', 'newFoo'); + expect(markDirtySpy).toHaveBeenCalledTimes(1); + + e.setInputValue('foo', NaN as any); + expect(markDirtySpy).toHaveBeenCalledTimes(2); + + e.setInputValue('foo', NaN as any); + expect(markDirtySpy).toHaveBeenCalledTimes(2); + }); + + it('should record an input change', () => { + e.connectedCallback(); + const component = e.componentRef !.instance; + + e.setInputValue('foo', 'newFoo'); + e.detectChanges(); + + expect(component.lastChanges).toEqual({ + foo: new SimpleChange(undefined, 'newFoo', true), + }); + + e.setInputValue('foo', 'newerFoo'); + e.setInputValue('bar', 'newBar'); + e.detectChanges(); + + expect(component.lastChanges).toEqual({ + foo: new SimpleChange('newFoo', 'newerFoo', false), + bar: new SimpleChange(undefined, 'newBar', true), + }); + }); + + it('should aggregate multiple recorded changes (retaining `firstChange`)', () => { + e.connectedCallback(); + const component = e.componentRef !.instance; + + e.setInputValue('foo', 'newFoo'); + e.setInputValue('foo', 'newerFoo'); + e.setInputValue('foo', 'newestFoo'); + e.detectChanges(); + + expect(component.lastChanges).toEqual({ + foo: new SimpleChange(undefined, 'newestFoo', true), + }); + + e.setInputValue('foo', 'newesterFoo'); + e.setInputValue('foo', 'newestestFoo'); + e.detectChanges(); + + expect(component.lastChanges).toEqual({ + foo: new SimpleChange('newestFoo', 'newestestFoo', false), + }); + }); + + it('should throw when disconnected', () => { + const errorMessage = + 'Calling \'setInputValue()\' on disconnected component \'TestComponent\' is not allowed.'; + + e.connectedCallback(); + disconnectSync(e); + + expect(() => e.setInputValue('foo', 'newFoo')).toThrowError(errorMessage); + }); + }); + + describe('component lifecycle hooks', () => { + let log: string[]; + + beforeEach(() => { + log = []; + + ['AfterContentChecked', 'AfterContentInit', 'AfterViewChecked', 'AfterViewInit', + 'DoCheck', 'OnChanges', 'OnDestroy', 'OnInit', + ] + .forEach( + hook => spyOn(TestComponent.prototype, `ng${hook}` as keyof TestComponent) + .and.callFake( + () => log.push(`${hook}(${NgZone.isInAngularZone()})`))); + }); + + it('should be run on initialization (with initial input changes)', () => { + e.bar = 'newBar'; + e.connectedCallback(); + + expect(log).toEqual([ + // Initialization and local change detection, due to `detectChanges()` (from + // `connectedCallback()`). + 'OnChanges(true)', + 'OnInit(true)', + 'DoCheck(true)', + 'AfterContentInit(true)', + 'AfterContentChecked(true)', + 'AfterViewInit(true)', + 'AfterViewChecked(true)', + // Global change detection, due to `ngZone.run()` (from `connectedCallback()`). + 'DoCheck(true)', + 'AfterContentChecked(true)', + 'AfterViewChecked(true)', + ]); + }); + + it('should be run on initialization (without initial input changes)', () => { + e.connectedCallback(); + + expect(log).toEqual([ + // Initialization and local change detection, due to `detectChanges()` (from + // `connectedCallback()`). + 'OnInit(true)', + 'DoCheck(true)', + 'AfterContentInit(true)', + 'AfterContentChecked(true)', + 'AfterViewInit(true)', + 'AfterViewChecked(true)', + // Global change detection, due to `ngZone.run()` (from `connectedCallback()`). + 'DoCheck(true)', + 'AfterContentChecked(true)', + 'AfterViewChecked(true)', + ]); + }); + + it('should be run on explicit change detection (with input changes)', () => { + e.connectedCallback(); + log.length = 0; + + e.bar = 'newBar'; + e.detectChanges(); + + expect(log).toEqual([ + // Local change detection, due to `detectChanges()`. + 'OnChanges(true)', + 'DoCheck(true)', + 'AfterContentChecked(true)', + 'AfterViewChecked(true)', + // Global change detection, due to `ngZone.run()` (from `detectChanges()`). + 'DoCheck(true)', + 'AfterContentChecked(true)', + 'AfterViewChecked(true)', + ]); + }); + + it('should be run on explicit change detection (without input changes)', () => { + e.connectedCallback(); + log.length = 0; + + e.detectChanges(); + + expect(log).toEqual([ + // Local change detection, due to `detectChanges()`. + 'DoCheck(true)', + 'AfterContentChecked(true)', + 'AfterViewChecked(true)', + // Global change detection, due to `ngZone.run()` (from `detectChanges()`). + 'DoCheck(true)', + 'AfterContentChecked(true)', + 'AfterViewChecked(true)', + ]); + }); + + it('should be run on implicit change detection (with input changes)', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + + e.connectedCallback(); + log.length = 0; + + e.bar = 'newBar'; + appRef.tick(); + + expect(NgZone.isInAngularZone()).toBe(false); + expect(log).toEqual([ + // Since inputs are updated outside of Angular + // `appRef` doesn't know about them (so no `ngOnChanges()`). + 'DoCheck(false)', + 'AfterContentChecked(false)', + 'AfterViewChecked(false)', + ]); + }); + + it('should be run on implicit change detection (without input changes)', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + + e.connectedCallback(); + log.length = 0; + + appRef.tick(); + + expect(NgZone.isInAngularZone()).toBe(false); + expect(log).toEqual([ + 'DoCheck(false)', + 'AfterContentChecked(false)', + 'AfterViewChecked(false)', + ]); + }); + + it('should be run on destruction', () => { + e.connectedCallback(); + log.length = 0; + + disconnectSync(e); + + expect(log).toEqual([ + 'OnDestroy(true)', + ]); + }); + + it('should not be run after destruction', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + + e.connectedCallback(); + disconnectSync(e); + log.length = 0; + + appRef.tick(); + + expect(log).toEqual([]); + }); + }); + }); + }); + }); + + // Helpers + @Component({ + selector: 'test-component-for-nge', + template: 'TestComponent|' + + 'foo({{ foo }})|' + + 'bar({{ bar }})|' + + 'baz()|' + + 'rest()', + }) + class TestComponent implements AfterContentChecked, + AfterContentInit, AfterViewChecked, AfterViewInit, DoCheck, OnChanges, OnDestroy, OnInit { + @Input() foo: string = 'foo'; + @Input('b-a-r') bar: string; + createdInNgZone = NgZone.isInAngularZone(); + lastChanges: SimpleChanges; + + @Output() baz = new EventEmitter(); + @Output('q-u-x') qux = new EventEmitter(); + + constructor(@Inject('TEST_VALUE') public testValue: string) {} + + ngOnChanges(changes: SimpleChanges) { this.lastChanges = changes; } + + ngAfterContentChecked() {} + ngAfterContentInit() {} + ngAfterViewChecked() {} + ngAfterViewInit() {} + ngDoCheck() {} + ngOnDestroy() {} + ngOnInit() {} + } + + @NgModule({ + imports: [BrowserModule], + providers: [ + {provide: 'TEST_VALUE', useValue: 'TEST'}, + ], + declarations: [TestComponent], + entryComponents: [TestComponent], + }) + class TestModule { + ngDoBootstrap() {} + } + + class TestNgElement extends NgElementImpl { + static is = 'test-component-for-nge'; + static observedAttributes = ['foo', 'b-a-r']; + + get foo() { return this.getInputValue('foo'); } + set foo(v) { this.setInputValue('foo', v); } + + get bar() { return this.getInputValue('bar'); } + set bar(v) { this.setInputValue('bar', v); } + + constructor() { + const appContext = new NgElementApplicationContext(moduleRef.injector); + const factory = moduleRef.componentFactoryResolver.resolveComponentFactory(TestComponent); + + const inputs = factory.inputs.map(({propName, templateName}) => ({ + propName, + attrName: templateName, + })); + const outputs = factory.outputs.map(({propName, templateName}) => ({ + propName, + eventName: templateName, + })); + + super(appContext, factory, inputs, outputs); + } + } + + // The `@webcomponents/custom-elements/src/native-shim.js` polyfill, that we use to enable + // ES2015 classes transpiled to ES5 constructor functions to be used as Custom Elements in + // tests, only works if the elements have been registered with `customElements.define()`. + customElements.define(TestNgElement.is, TestNgElement); + }); +} diff --git a/packages/elements/testing/index.ts b/packages/elements/testing/index.ts index 845ab36f6b..ac4794ea10 100644 --- a/packages/elements/testing/index.ts +++ b/packages/elements/testing/index.ts @@ -98,3 +98,18 @@ export function installMockScheduler(isSync?: boolean): AsyncMockScheduler|SyncM return mockScheduler; } + +export function patchEnv() { + // This helper function is defined in `test-main.js`. See there for more details. + (window as any).$$patchInnerHtmlProp(); +} + +export function restoreEnv() { + // This helper function is defined in `test-main.js`. See there for more details. + (window as any).$$restoreInnerHtmlProp(); +} + +export function supportsCustomElements() { + // The browser does not natively support custom elements and is not polyfillable. + return typeof customElements !== 'undefined'; +} diff --git a/test-main.js b/test-main.js index 4f0cd019b3..6fd4dfd992 100644 --- a/test-main.js +++ b/test-main.js @@ -67,21 +67,35 @@ System.config({ }); -// Set up the test injector, then import all the specs, execute their `main()` -// method and kick off Karma (Jasmine). -System.import('@angular/core/testing') - .then(function(coreTesting) { - return Promise - .all([ - System.import('@angular/platform-browser-dynamic/testing'), - System.import('@angular/platform-browser/animations') - ]) - .then(function(mods) { - coreTesting.TestBed.initTestEnvironment( - [mods[0].BrowserDynamicTestingModule, mods[1].NoopAnimationsModule], - mods[0].platformBrowserDynamicTesting()); - }); +// Load browser-specific CustomElement polyfills, set up the test injector, import all the specs, +// execute their `main()` method and kick off Karma (Jasmine). +Promise + .resolve() + + // Load browser-specific polyfills for custom elements. + .then(function() { return loadCustomElementsPolyfills(); }) + + // Load necessary testing packages. + .then(function() { + return Promise.all([ + System.import('@angular/core/testing'), + System.import('@angular/platform-browser-dynamic/testing'), + System.import('@angular/platform-browser/animations') + ]); }) + + // Set up the test injector. + .then(function(mods) { + var coreTesting = mods[0]; + var pbdTesting = mods[1]; + var pbAnimations = mods[2]; + + coreTesting.TestBed.initTestEnvironment( + [pbdTesting.BrowserDynamicTestingModule, pbAnimations.NoopAnimationsModule], + pbdTesting.platformBrowserDynamicTesting()); + }) + + // Import all the specs and execute their `main()` method. .then(function() { return Promise.all(Object .keys(window.__karma__.files) // All files served by Karma. @@ -98,9 +112,105 @@ System.import('@angular/core/testing') }); })); }) + + // Kick off karma (Jasmine). .then(function() { __karma__.start(); }, function(error) { console.error(error); }); +function loadCustomElementsPolyfills() { + var loadedPromise = Promise.resolve(); + + // The custom elements polyfill relies on `MutationObserver`. + if (!window.MutationObserver) { + loadedPromise = + loadedPromise + .then(function() { return System.import('node_modules/mutation-observer/index.js'); }) + .then(function(MutationObserver) { window.MutationObserver = MutationObserver; }); + } + + // The custom elements polyfill relies on `Object.setPrototypeOf()`. + if (!Object.setPrototypeOf) { + var getDescriptor = function getDescriptor(obj, prop) { + var descriptor; + while (obj && !descriptor) { + descriptor = Object.getOwnPropertyDescriptor(obj, prop); + obj = Object.getPrototypeOf(obj); + } + return descriptor || {}; + }; + var setPrototypeOf = function setPrototypeOf(obj, proto) { + for (var prop in proto) { + if (!obj.hasOwnProperty(prop)) { + Object.defineProperty(obj, prop, getDescriptor(proto, prop)); + } + } + return obj; + }; + + Object.setPrototypeOf = setPrototypeOf; + } + + // The custom elements polyfill will patch `(HTML)Element` properties, including `innerHTML`: + // https://github.com/webcomponents/custom-elements/blob/32f043c3a5e5fc3e035342c0ef10c6786fa416d7/src/Patch/Element.js#L28-L78 + // The patched `innerHTML` setter will try to traverse the DOM (via `nextSibling`), which leads to + // infinite loops when testing `HtmlSanitizer` with cloberred elements on browsers that do not + // support the `