From 0799f184dc01e52ffa363caa25bb4fe3c7929de9 Mon Sep 17 00:00:00 2001 From: Ward Bell Date: Tue, 4 Apr 2017 14:24:24 -0600 Subject: [PATCH] fix(aio): v2 - when browser lacks service worker, disable sw feature --- .../app/sw-updates/noop-ng-service-worker.ts | 35 ++ aio/src/app/sw-updates/sw-updates.module.ts | 3 +- .../app/sw-updates/sw-updates.service.spec.ts | 334 ++++++++++-------- aio/src/app/sw-updates/sw-updates.service.ts | 13 +- 4 files changed, 232 insertions(+), 153 deletions(-) create mode 100644 aio/src/app/sw-updates/noop-ng-service-worker.ts diff --git a/aio/src/app/sw-updates/noop-ng-service-worker.ts b/aio/src/app/sw-updates/noop-ng-service-worker.ts new file mode 100644 index 0000000000..d94b6c3c8d --- /dev/null +++ b/aio/src/app/sw-updates/noop-ng-service-worker.ts @@ -0,0 +1,35 @@ +// Alternative to NgServiceWorker when the browser doesn't support NgServiceWorker +// +// Many browsers do not support ServiceWorker (e.g, Safari). +// The Angular NgServiceWorker assumes that the browser supports ServiceWorker +// and starts talking to it immediately in its constructor without checking if it exists. +// Merely injecting the `NgServiceWorker` is an exception in any browser w/o ServiceWorker. +// +// Solution: when the browser doesn't support service worker and a class injects `NgServiceWorker` +// substitute the inert `NoopNgServiceWorker`. + +import { Injector } from '@angular/core'; +import { NgServiceWorker } from '@angular/service-worker'; + +import { Observable } from 'rxjs/Observable'; +import { of } from 'rxjs/observable/of'; + +export class NoopNgServiceWorker { + // Service worker is supported if `navigator['serviceWorker'] is defined. + isServiceWorkerSupported = !!navigator['serviceWorker']; + + checkForUpdate() { return of(false); } + activateUpdate(version: string) { return of(false); } +} + +export abstract class NgServiceWorkerForReals {} + +export function NgServiceWorkerFactory(injector: Injector, nsw: NoopNgServiceWorker) { + return nsw.isServiceWorkerSupported ? injector.get(NgServiceWorkerForReals) : nsw; +} + +export const noopNgServiceWorkerProviders = [ + NoopNgServiceWorker, + { provide: NgServiceWorkerForReals, useClass: NgServiceWorker }, + { provide: NgServiceWorker, useFactory: NgServiceWorkerFactory, + deps: [Injector, NoopNgServiceWorker] }]; diff --git a/aio/src/app/sw-updates/sw-updates.module.ts b/aio/src/app/sw-updates/sw-updates.module.ts index 3e0ee78c93..eea4900b19 100644 --- a/aio/src/app/sw-updates/sw-updates.module.ts +++ b/aio/src/app/sw-updates/sw-updates.module.ts @@ -2,16 +2,17 @@ import { NgModule } from '@angular/core'; import { MdSnackBarModule } from '@angular/material'; import { ServiceWorkerModule } from '@angular/service-worker'; +import { noopNgServiceWorkerProviders } from './noop-ng-service-worker'; import { SwUpdateNotificationsService } from './sw-update-notifications.service'; import { SwUpdatesService } from './sw-updates.service'; - @NgModule({ imports: [ MdSnackBarModule, ServiceWorkerModule ], providers: [ + noopNgServiceWorkerProviders, SwUpdateNotificationsService, SwUpdatesService ] diff --git a/aio/src/app/sw-updates/sw-updates.service.spec.ts b/aio/src/app/sw-updates/sw-updates.service.spec.ts index 6285ca64ee..29993cc42e 100644 --- a/aio/src/app/sw-updates/sw-updates.service.spec.ts +++ b/aio/src/app/sw-updates/sw-updates.service.spec.ts @@ -1,32 +1,41 @@ import { ReflectiveInjector } from '@angular/core'; import { fakeAsync, tick } from '@angular/core/testing'; import { NgServiceWorker } from '@angular/service-worker'; +import { of } from 'rxjs/observable/of'; import { Subject } from 'rxjs/Subject'; import 'rxjs/add/operator/take'; +import { NgServiceWorkerForReals, NoopNgServiceWorker, noopNgServiceWorkerProviders } from './noop-ng-service-worker'; import { SwUpdatesService } from './sw-updates.service'; - describe('SwUpdatesService', () => { let injector: ReflectiveInjector; let service: SwUpdatesService; let sw: MockNgServiceWorker; + let nsw: NoopNgServiceWorker; let checkInterval: number; + let isServiceWorkerSupportedInTest: boolean; // Helpers // NOTE: - // Because `SwUpdatesService` uses the `debounceTime` operator, it needs to be instantiated - // inside the `fakeAsync` zone (when `fakeAsync` is used for the test). Thus, we can't run - // `setup()` in a `beforeEach()` block. We use the `run()` helper to call it inside each test's - // zone. + // Because `SwUpdatesService` uses the `debounceTime` operator, it needs to be instantiated + // inside the `fakeAsync` zone (when `fakeAsync` is used for the test). Thus, we can't run + // `setup()` in a `beforeEach()` block. We use the `run()` helper to call it inside each test' zone. const setup = () => { injector = ReflectiveInjector.resolveAndCreate([ - { provide: NgServiceWorker, useClass: MockNgServiceWorker }, + noopNgServiceWorkerProviders, + { provide: NgServiceWorkerForReals, useClass: MockNgServiceWorker }, + { provide: NoopNgServiceWorker, useClass: MockNoopNgServiceWorker }, SwUpdatesService ]); + + nsw = injector.get(NoopNgServiceWorker); + // Set whether service worker exists before getting the SwUpdatesService! + nsw.isServiceWorkerSupported = isServiceWorkerSupportedInTest; + service = injector.get(SwUpdatesService); - sw = injector.get(NgServiceWorker); checkInterval = (service as any).checkInterval; + sw = injector.get(NgServiceWorkerForReals); }; const tearDown = () => service.ngOnDestroy(); const run = specFn => () => { @@ -35,178 +44,212 @@ describe('SwUpdatesService', () => { tearDown(); }; + describe('when service worker is supported', () => { - it('should create', run(() => { - expect(service).toBeTruthy(); - })); + beforeEach(() => { + isServiceWorkerSupportedInTest = true; + }); - it('should immediatelly check for updates when instantiated', run(() => { - expect(sw.checkForUpdate).toHaveBeenCalled(); - })); - - it('should schedule a new check if there is no update available', fakeAsync(run(() => { - sw.checkForUpdate.calls.reset(); - - sw.$$checkForUpdateSubj.next(false); - expect(sw.checkForUpdate).not.toHaveBeenCalled(); - - tick(checkInterval); - expect(sw.checkForUpdate).toHaveBeenCalled(); - }))); - - it('should not schedule a new check if there is an update available', fakeAsync(run(() => { - sw.checkForUpdate.calls.reset(); - - sw.$$checkForUpdateSubj.next(true); - expect(sw.checkForUpdate).not.toHaveBeenCalled(); - - tick(checkInterval); - expect(sw.checkForUpdate).not.toHaveBeenCalled(); - }))); - - describe('#activateUpdate()', () => { - it('should return a promise', run(() => { - expect(service.activateUpdate()).toEqual(jasmine.any(Promise)); + it('should create', run(() => { + expect(service).toBeTruthy(); })); - it('should call `NgServiceWorker.activateUpdate()`', run(() => { - expect(sw.activateUpdate).not.toHaveBeenCalled(); - - service.activateUpdate(); - expect(sw.activateUpdate).toHaveBeenCalled(); + it('should call the NgServiceWorker', run(() => { + // does not call the Angular ServiceWorker + expect(sw.checkForUpdate).toHaveBeenCalled(); + // calls the noop Angular ServiceWorker instead + expect(nsw.checkForUpdate).not.toHaveBeenCalled(); })); - it('should not pass a specific version to `NgServiceWorker.activateUpdate()`', run(() => { - (service.activateUpdate as Function)('foo'); - expect(sw.activateUpdate).toHaveBeenCalledWith(null); + it('should immediatelly check for updates when instantiated', run(() => { + expect(sw.checkForUpdate).toHaveBeenCalled(); })); - it('should resolve the promise with the activation outcome', fakeAsync(run(() => { - let outcome; - - service.activateUpdate().then(v => outcome = v); - sw.$$activateUpdateSubj.next(true); - tick(); - expect(outcome).toBe(true); - - service.activateUpdate().then(v => outcome = v); - sw.$$activateUpdateSubj.next(false); - tick(); - expect(outcome).toBe(false); - }))); - - it('should schedule a new check (if the activation succeeded)', fakeAsync(run(() => { + it('should schedule a new check if there is no update available', fakeAsync(run(() => { sw.checkForUpdate.calls.reset(); - service.activateUpdate(); - - tick(checkInterval); - expect(sw.checkForUpdate).not.toHaveBeenCalled(); - - sw.$$activateUpdateSubj.next(true); + sw.$$checkForUpdateSubj.next(false); expect(sw.checkForUpdate).not.toHaveBeenCalled(); tick(checkInterval); expect(sw.checkForUpdate).toHaveBeenCalled(); }))); - it('should schedule a new check (if the activation failed)', fakeAsync(run(() => { + it('should not schedule a new check if there is an update available', fakeAsync(run(() => { sw.checkForUpdate.calls.reset(); - service.activateUpdate(); - - tick(checkInterval); - expect(sw.checkForUpdate).not.toHaveBeenCalled(); - - sw.$$activateUpdateSubj.next(false); + sw.$$checkForUpdateSubj.next(true); expect(sw.checkForUpdate).not.toHaveBeenCalled(); tick(checkInterval); - expect(sw.checkForUpdate).toHaveBeenCalled(); + expect(sw.checkForUpdate).not.toHaveBeenCalled(); }))); - }); - describe('#isUpdateAvailable', () => { - let emittedValues: boolean[]; + describe('#activateUpdate()', () => { + it('should return a promise', run(() => { + expect(service.activateUpdate()).toEqual(jasmine.any(Promise)); + })); - // Helpers - const withSubscription = specFn => () => { - emittedValues = []; - service.isUpdateAvailable.subscribe(v => emittedValues.push(v)); - specFn(); - }; - - - it('should emit `false/true` when there is/isn\'t an update available', - fakeAsync(run(withSubscription(() => { - expect(emittedValues).toEqual([]); - - sw.$$checkForUpdateSubj.next(false); - expect(emittedValues).toEqual([false]); - - tick(checkInterval); - sw.$$checkForUpdateSubj.next(true); - expect(emittedValues).toEqual([false, true]); - }))) - ); - - it('should emit only when the value has changed', - fakeAsync(run(withSubscription(() => { - expect(emittedValues).toEqual([]); - - sw.$$checkForUpdateSubj.next(false); - expect(emittedValues).toEqual([false]); - - tick(checkInterval); - sw.$$checkForUpdateSubj.next(false); - expect(emittedValues).toEqual([false]); - - tick(checkInterval); - sw.$$checkForUpdateSubj.next(false); - expect(emittedValues).toEqual([false]); - }))) - ); - - it('should emit `false` after a successful activation', - fakeAsync(run(withSubscription(() => { - sw.$$checkForUpdateSubj.next(true); - expect(emittedValues).toEqual([true]); + it('should call `NgServiceWorker.activateUpdate()`', run(() => { + expect(sw.activateUpdate).not.toHaveBeenCalled(); service.activateUpdate(); + expect(sw.activateUpdate).toHaveBeenCalled(); + })); + + it('should not pass a specific version to `NgServiceWorker.activateUpdate()`', run(() => { + (service.activateUpdate as Function)('foo'); + expect(sw.activateUpdate).toHaveBeenCalledWith(null); + })); + + it('should resolve the promise with the activation outcome', fakeAsync(run(() => { + let outcome; + + service.activateUpdate().then(v => outcome = v); sw.$$activateUpdateSubj.next(true); + tick(); + expect(outcome).toBe(true); - expect(emittedValues).toEqual([true, false]); - }))) - ); - - it('should emit `false` after a failed activation', - fakeAsync(run(withSubscription(() => { - sw.$$checkForUpdateSubj.next(true); - expect(emittedValues).toEqual([true]); - - service.activateUpdate(); + service.activateUpdate().then(v => outcome = v); sw.$$activateUpdateSubj.next(false); + tick(); + expect(outcome).toBe(false); + }))); - expect(emittedValues).toEqual([true, false]); - }))) - ); - - it('should emit not emit a vew value after activation if already `false`', - fakeAsync(run(withSubscription(() => { - sw.$$checkForUpdateSubj.next(false); - expect(emittedValues).toEqual([false]); + it('should schedule a new check (if the activation succeeded)', fakeAsync(run(() => { + sw.checkForUpdate.calls.reset(); service.activateUpdate(); - sw.$$activateUpdateSubj.next(true); - expect(emittedValues).toEqual([false]); - }))) - ); + tick(checkInterval); + expect(sw.checkForUpdate).not.toHaveBeenCalled(); + + sw.$$activateUpdateSubj.next(true); + expect(sw.checkForUpdate).not.toHaveBeenCalled(); + + tick(checkInterval); + expect(sw.checkForUpdate).toHaveBeenCalled(); + }))); + + it('should schedule a new check (if the activation failed)', fakeAsync(run(() => { + sw.checkForUpdate.calls.reset(); + + service.activateUpdate(); + + tick(checkInterval); + expect(sw.checkForUpdate).not.toHaveBeenCalled(); + + sw.$$activateUpdateSubj.next(false); + expect(sw.checkForUpdate).not.toHaveBeenCalled(); + + tick(checkInterval); + expect(sw.checkForUpdate).toHaveBeenCalled(); + }))); + }); + + describe('#isUpdateAvailable', () => { + let emittedValues: boolean[]; + + // Helpers + const withSubscription = specFn => () => { + emittedValues = []; + service.isUpdateAvailable.subscribe(v => emittedValues.push(v)); + specFn(); + }; + + + it('should emit `false/true` when there is/isn\'t an update available', + fakeAsync(run(withSubscription(() => { + expect(emittedValues).toEqual([]); + + sw.$$checkForUpdateSubj.next(false); + expect(emittedValues).toEqual([false]); + + tick(checkInterval); + sw.$$checkForUpdateSubj.next(true); + expect(emittedValues).toEqual([false, true]); + }))) + ); + + it('should emit only when the value has changed', + fakeAsync(run(withSubscription(() => { + expect(emittedValues).toEqual([]); + + sw.$$checkForUpdateSubj.next(false); + expect(emittedValues).toEqual([false]); + + tick(checkInterval); + sw.$$checkForUpdateSubj.next(false); + expect(emittedValues).toEqual([false]); + + tick(checkInterval); + sw.$$checkForUpdateSubj.next(false); + expect(emittedValues).toEqual([false]); + }))) + ); + + it('should emit `false` after a successful activation', + fakeAsync(run(withSubscription(() => { + sw.$$checkForUpdateSubj.next(true); + expect(emittedValues).toEqual([true]); + + service.activateUpdate(); + sw.$$activateUpdateSubj.next(true); + + expect(emittedValues).toEqual([true, false]); + }))) + ); + + it('should emit `false` after a failed activation', + fakeAsync(run(withSubscription(() => { + sw.$$checkForUpdateSubj.next(true); + expect(emittedValues).toEqual([true]); + + service.activateUpdate(); + sw.$$activateUpdateSubj.next(false); + + expect(emittedValues).toEqual([true, false]); + }))) + ); + + it('should not emit a new value after activation if already `false`', + fakeAsync(run(withSubscription(() => { + sw.$$checkForUpdateSubj.next(false); + expect(emittedValues).toEqual([false]); + + service.activateUpdate(); + sw.$$activateUpdateSubj.next(true); + + expect(emittedValues).toEqual([false]); + }))) + ); + }); }); + + describe('when service worker isn\'t supported (Safari)', () => { + + beforeEach(() => { + isServiceWorkerSupportedInTest = false; + }); + + it('should create', run(() => { + expect(service).toBeTruthy(); + })); + + it('should call the NoopNgServiceWorker', run(() => { + // does not call the Angular ServiceWorker + expect(sw.checkForUpdate).not.toHaveBeenCalled(); + // calls the noop Angular ServiceWorker instead + expect(nsw.checkForUpdate).toHaveBeenCalled(); + })); + + }); + }); // Mocks class MockNgServiceWorker { + $$activateUpdateSubj = new Subject(); $$checkForUpdateSubj = new Subject(); @@ -216,3 +259,12 @@ class MockNgServiceWorker { checkForUpdate = jasmine.createSpy('MockNgServiceWorker.checkForUpdate') .and.callFake(() => this.$$checkForUpdateSubj.take(1)); } + +class MockNoopNgServiceWorker extends NoopNgServiceWorker { + constructor() { + super(); + this.isServiceWorkerSupported = true; // assume it is by default + spyOn(this, 'activateUpdate').and.callThrough(); + spyOn(this, 'checkForUpdate').and.callThrough(); + } +} diff --git a/aio/src/app/sw-updates/sw-updates.service.ts b/aio/src/app/sw-updates/sw-updates.service.ts index 86a75037f3..60a9ef1fcb 100644 --- a/aio/src/app/sw-updates/sw-updates.service.ts +++ b/aio/src/app/sw-updates/sw-updates.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Injector, OnDestroy } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; import { NgServiceWorker } from '@angular/service-worker'; import { Observable } from 'rxjs/Observable'; import { ReplaySubject } from 'rxjs/ReplaySubject'; @@ -36,13 +36,9 @@ export class SwUpdatesService implements OnDestroy { private onDestroy = new Subject(); private checkForUpdateSubj = new Subject(); private isUpdateAvailableSubj = new ReplaySubject(1); - private sw: NgServiceWorker | NullServiceWorker = null; isUpdateAvailable = this.isUpdateAvailableSubj.distinctUntilChanged(); - isServiceWorkAvailable = !!navigator['serviceWorker']; - - constructor(injector: Injector) { - this.sw = this.isServiceWorkAvailable ? injector.get(NgServiceWorker) : new NullServiceWorker(); + constructor(private sw: NgServiceWorker) { this.checkForUpdateSubj .debounceTime(this.checkInterval) .takeUntil(this.onDestroy) @@ -78,8 +74,3 @@ export class SwUpdatesService implements OnDestroy { .subscribe(v => this.isUpdateAvailableSubj.next(v)); } } - -class NullServiceWorker { - checkForUpdate() { return Observable.of(false); } - activateUpdate(version: string) { return Observable.of(false); } -}