fix(aio): v2 - when browser lacks service worker, disable sw feature

This commit is contained in:
Ward Bell 2017-04-04 14:24:24 -06:00 committed by Hans
parent c530c5d317
commit 0799f184dc
4 changed files with 232 additions and 153 deletions

View File

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

View File

@ -2,16 +2,17 @@ import { NgModule } from '@angular/core';
import { MdSnackBarModule } from '@angular/material'; import { MdSnackBarModule } from '@angular/material';
import { ServiceWorkerModule } from '@angular/service-worker'; import { ServiceWorkerModule } from '@angular/service-worker';
import { noopNgServiceWorkerProviders } from './noop-ng-service-worker';
import { SwUpdateNotificationsService } from './sw-update-notifications.service'; import { SwUpdateNotificationsService } from './sw-update-notifications.service';
import { SwUpdatesService } from './sw-updates.service'; import { SwUpdatesService } from './sw-updates.service';
@NgModule({ @NgModule({
imports: [ imports: [
MdSnackBarModule, MdSnackBarModule,
ServiceWorkerModule ServiceWorkerModule
], ],
providers: [ providers: [
noopNgServiceWorkerProviders,
SwUpdateNotificationsService, SwUpdateNotificationsService,
SwUpdatesService SwUpdatesService
] ]

View File

@ -1,32 +1,41 @@
import { ReflectiveInjector } from '@angular/core'; import { ReflectiveInjector } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing'; import { fakeAsync, tick } from '@angular/core/testing';
import { NgServiceWorker } from '@angular/service-worker'; import { NgServiceWorker } from '@angular/service-worker';
import { of } from 'rxjs/observable/of';
import { Subject } from 'rxjs/Subject'; import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/take'; import 'rxjs/add/operator/take';
import { NgServiceWorkerForReals, NoopNgServiceWorker, noopNgServiceWorkerProviders } from './noop-ng-service-worker';
import { SwUpdatesService } from './sw-updates.service'; import { SwUpdatesService } from './sw-updates.service';
describe('SwUpdatesService', () => { describe('SwUpdatesService', () => {
let injector: ReflectiveInjector; let injector: ReflectiveInjector;
let service: SwUpdatesService; let service: SwUpdatesService;
let sw: MockNgServiceWorker; let sw: MockNgServiceWorker;
let nsw: NoopNgServiceWorker;
let checkInterval: number; let checkInterval: number;
let isServiceWorkerSupportedInTest: boolean;
// Helpers // Helpers
// NOTE: // NOTE:
// Because `SwUpdatesService` uses the `debounceTime` operator, it needs to be instantiated // 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 // 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 // `setup()` in a `beforeEach()` block. We use the `run()` helper to call it inside each test' zone.
// zone.
const setup = () => { const setup = () => {
injector = ReflectiveInjector.resolveAndCreate([ injector = ReflectiveInjector.resolveAndCreate([
{ provide: NgServiceWorker, useClass: MockNgServiceWorker }, noopNgServiceWorkerProviders,
{ provide: NgServiceWorkerForReals, useClass: MockNgServiceWorker },
{ provide: NoopNgServiceWorker, useClass: MockNoopNgServiceWorker },
SwUpdatesService SwUpdatesService
]); ]);
nsw = injector.get(NoopNgServiceWorker);
// Set whether service worker exists before getting the SwUpdatesService!
nsw.isServiceWorkerSupported = isServiceWorkerSupportedInTest;
service = injector.get(SwUpdatesService); service = injector.get(SwUpdatesService);
sw = injector.get(NgServiceWorker);
checkInterval = (service as any).checkInterval; checkInterval = (service as any).checkInterval;
sw = injector.get(NgServiceWorkerForReals);
}; };
const tearDown = () => service.ngOnDestroy(); const tearDown = () => service.ngOnDestroy();
const run = specFn => () => { const run = specFn => () => {
@ -35,178 +44,212 @@ describe('SwUpdatesService', () => {
tearDown(); tearDown();
}; };
describe('when service worker is supported', () => {
it('should create', run(() => { beforeEach(() => {
expect(service).toBeTruthy(); isServiceWorkerSupportedInTest = true;
})); });
it('should immediatelly check for updates when instantiated', run(() => { it('should create', run(() => {
expect(sw.checkForUpdate).toHaveBeenCalled(); expect(service).toBeTruthy();
}));
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 call `NgServiceWorker.activateUpdate()`', run(() => { it('should call the NgServiceWorker', run(() => {
expect(sw.activateUpdate).not.toHaveBeenCalled(); // does not call the Angular ServiceWorker
expect(sw.checkForUpdate).toHaveBeenCalled();
service.activateUpdate(); // calls the noop Angular ServiceWorker instead
expect(sw.activateUpdate).toHaveBeenCalled(); expect(nsw.checkForUpdate).not.toHaveBeenCalled();
})); }));
it('should not pass a specific version to `NgServiceWorker.activateUpdate()`', run(() => { it('should immediatelly check for updates when instantiated', run(() => {
(service.activateUpdate as Function)('foo'); expect(sw.checkForUpdate).toHaveBeenCalled();
expect(sw.activateUpdate).toHaveBeenCalledWith(null);
})); }));
it('should resolve the promise with the activation outcome', fakeAsync(run(() => { it('should schedule a new check if there is no update available', 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(() => {
sw.checkForUpdate.calls.reset(); sw.checkForUpdate.calls.reset();
service.activateUpdate(); sw.$$checkForUpdateSubj.next(false);
tick(checkInterval);
expect(sw.checkForUpdate).not.toHaveBeenCalled();
sw.$$activateUpdateSubj.next(true);
expect(sw.checkForUpdate).not.toHaveBeenCalled(); expect(sw.checkForUpdate).not.toHaveBeenCalled();
tick(checkInterval); tick(checkInterval);
expect(sw.checkForUpdate).toHaveBeenCalled(); 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(); sw.checkForUpdate.calls.reset();
service.activateUpdate(); sw.$$checkForUpdateSubj.next(true);
tick(checkInterval);
expect(sw.checkForUpdate).not.toHaveBeenCalled();
sw.$$activateUpdateSubj.next(false);
expect(sw.checkForUpdate).not.toHaveBeenCalled(); expect(sw.checkForUpdate).not.toHaveBeenCalled();
tick(checkInterval); tick(checkInterval);
expect(sw.checkForUpdate).toHaveBeenCalled(); expect(sw.checkForUpdate).not.toHaveBeenCalled();
}))); })));
});
describe('#isUpdateAvailable', () => { describe('#activateUpdate()', () => {
let emittedValues: boolean[]; it('should return a promise', run(() => {
expect(service.activateUpdate()).toEqual(jasmine.any(Promise));
}));
// Helpers it('should call `NgServiceWorker.activateUpdate()`', run(() => {
const withSubscription = specFn => () => { expect(sw.activateUpdate).not.toHaveBeenCalled();
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(); 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); sw.$$activateUpdateSubj.next(true);
tick();
expect(outcome).toBe(true);
expect(emittedValues).toEqual([true, false]); service.activateUpdate().then(v => outcome = v);
})))
);
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); sw.$$activateUpdateSubj.next(false);
tick();
expect(outcome).toBe(false);
})));
expect(emittedValues).toEqual([true, false]); it('should schedule a new check (if the activation succeeded)', fakeAsync(run(() => {
}))) sw.checkForUpdate.calls.reset();
);
it('should emit not emit a vew value after activation if already `false`',
fakeAsync(run(withSubscription(() => {
sw.$$checkForUpdateSubj.next(false);
expect(emittedValues).toEqual([false]);
service.activateUpdate(); 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 // Mocks
class MockNgServiceWorker { class MockNgServiceWorker {
$$activateUpdateSubj = new Subject<boolean>(); $$activateUpdateSubj = new Subject<boolean>();
$$checkForUpdateSubj = new Subject<boolean>(); $$checkForUpdateSubj = new Subject<boolean>();
@ -216,3 +259,12 @@ class MockNgServiceWorker {
checkForUpdate = jasmine.createSpy('MockNgServiceWorker.checkForUpdate') checkForUpdate = jasmine.createSpy('MockNgServiceWorker.checkForUpdate')
.and.callFake(() => this.$$checkForUpdateSubj.take(1)); .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();
}
}

View File

@ -1,4 +1,4 @@
import { Injectable, Injector, OnDestroy } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { NgServiceWorker } from '@angular/service-worker'; import { NgServiceWorker } from '@angular/service-worker';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { ReplaySubject } from 'rxjs/ReplaySubject'; import { ReplaySubject } from 'rxjs/ReplaySubject';
@ -36,13 +36,9 @@ export class SwUpdatesService implements OnDestroy {
private onDestroy = new Subject(); private onDestroy = new Subject();
private checkForUpdateSubj = new Subject(); private checkForUpdateSubj = new Subject();
private isUpdateAvailableSubj = new ReplaySubject<boolean>(1); private isUpdateAvailableSubj = new ReplaySubject<boolean>(1);
private sw: NgServiceWorker | NullServiceWorker = null;
isUpdateAvailable = this.isUpdateAvailableSubj.distinctUntilChanged(); 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 this.checkForUpdateSubj
.debounceTime(this.checkInterval) .debounceTime(this.checkInterval)
.takeUntil(this.onDestroy) .takeUntil(this.onDestroy)
@ -78,8 +74,3 @@ export class SwUpdatesService implements OnDestroy {
.subscribe(v => this.isUpdateAvailableSubj.next(v)); .subscribe(v => this.isUpdateAvailableSubj.next(v));
} }
} }
class NullServiceWorker {
checkForUpdate() { return Observable.of(false); }
activateUpdate(version: string) { return Observable.of(false); }
}