feat(aio): enable activating available ServiceWorker updates (#15561)

This commit is contained in:
George Kalpakas 2017-03-30 09:55:03 +03:00 committed by Igor Minar
parent 67719f2185
commit 3ccbe28d9f
10 changed files with 636 additions and 3 deletions

View File

@ -17,6 +17,8 @@ import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
describe('AppComponent', () => {
let component: AppComponent;
@ -32,7 +34,8 @@ describe('AppComponent', () => {
{ provide: Http, useClass: TestHttp },
{ provide: LocationService, useFactory: () => new MockLocationService(initialUrl) },
{ provide: Logger, useClass: MockLogger },
{ provide: SearchService, useClass: MockSearchService }
{ provide: SearchService, useClass: MockSearchService },
{ provide: SwUpdateNotificationsService, useClass: MockSwUpdateNotificationsService },
]
});
TestBed.compileComponents();
@ -48,6 +51,13 @@ describe('AppComponent', () => {
expect(component).toBeDefined();
});
describe('ServiceWorker update notifications', () => {
it('should be enabled', () => {
const swUpdateNotifications = TestBed.get(SwUpdateNotificationsService) as SwUpdateNotificationsService;
expect(swUpdateNotifications.enable).toHaveBeenCalled();
});
});
describe('is Hamburger Visible', () => {
console.log('PENDING: AppComponent');
});

View File

@ -9,6 +9,8 @@ import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { LocationService } from 'app/shared/location.service';
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
const sideNavView = 'SideNav';
@ -54,7 +56,8 @@ export class AppComponent implements OnInit {
private autoScrollService: AutoScrollService,
private documentService: DocumentService,
private locationService: LocationService,
private navigationService: NavigationService
private navigationService: NavigationService,
private swUpdateNotifications: SwUpdateNotificationsService
) { }
ngOnInit() {
@ -83,6 +86,8 @@ export class AppComponent implements OnInit {
this.navigationService.versionInfo.subscribe( vi => this.versionInfo = vi );
this.swUpdateNotifications.enable();
this.onResize(window.innerWidth);
}

View File

@ -17,6 +17,8 @@ import { Platform } from '@angular/material/core';
// crashes with "missing first" operator when SideNav.mode is "over"
import 'rxjs/add/operator/first';
import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module';
import { AppComponent } from 'app/app.component';
import { ApiService } from 'app/embedded/api/api.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
@ -46,7 +48,8 @@ import { AutoScrollService } from 'app/shared/auto-scroll.service';
MdInputModule,
MdToolbarModule,
MdSidenavModule,
MdTabsModule
MdTabsModule,
SwUpdatesModule
],
declarations: [
AppComponent,

View File

@ -0,0 +1,193 @@
import { ReflectiveInjector } from '@angular/core';
import { fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { MdSnackBar, MdSnackBarConfig } from '@angular/material';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { MockSwUpdatesService } from 'testing/sw-updates.service';
import { SwUpdateNotificationsService } from './sw-update-notifications.service';
import { SwUpdatesService } from './sw-updates.service';
describe('SwUpdateNotificationsService', () => {
let injector: ReflectiveInjector;
let service: SwUpdateNotificationsService;
let swUpdates: MockSwUpdatesService;
let snackBar: MockMdSnackBar;
// Helpers
const activateUpdate = success => {
swUpdates.$$isUpdateAvailableSubj.next(true);
snackBar.$$lastRef.$$onActionSubj.next();
swUpdates.$$activateUpdateSubj.next(success);
flushMicrotasks();
};
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
{ provide: MdSnackBar, useClass: MockMdSnackBar },
{ provide: SwUpdatesService, useClass: MockSwUpdatesService },
SwUpdateNotificationsService
]);
service = injector.get(SwUpdateNotificationsService);
swUpdates = injector.get(SwUpdatesService);
snackBar = injector.get(MdSnackBar);
});
it('should create', () => {
expect(service).toBeTruthy();
});
it('should not notify about available updates before being enabled', () => {
swUpdates.$$isUpdateAvailableSubj.next(true);
expect(snackBar.$$lastRef).toBeUndefined();
});
describe('when enabled', () => {
beforeEach(() => service.enable());
it('should not re-subscribe to updates if already enabled', () => {
spyOn(snackBar, 'open').and.callThrough();
service.enable();
swUpdates.$$isUpdateAvailableSubj.next(true);
expect(snackBar.open).toHaveBeenCalledTimes(1);
});
it('should notify when updates are available', () => {
expect(snackBar.$$lastRef).toBeUndefined();
swUpdates.$$isUpdateAvailableSubj.next(true);
expect(snackBar.$$lastRef.$$message).toContain('ServiceWorker update available');
expect(snackBar.$$lastRef.$$action).toBe('Activate');
expect(snackBar.$$lastRef.$$config.duration).toBeUndefined();
});
it('should not notify when updates are not available', () => {
swUpdates.$$isUpdateAvailableSubj.next(false);
expect(snackBar.$$lastRef).toBeUndefined();
});
it('should activate the update when clicking on `Activate`', () => {
spyOn(swUpdates, 'activateUpdate').and.callThrough();
swUpdates.$$isUpdateAvailableSubj.next(true);
expect(swUpdates.activateUpdate).not.toHaveBeenCalled();
snackBar.$$lastRef.$$onActionSubj.next();
expect(swUpdates.activateUpdate).toHaveBeenCalled();
});
it('should report a successful activation', fakeAsync(() => {
activateUpdate(true);
expect(snackBar.$$lastRef.$$message).toContain('Update activated successfully');
expect(snackBar.$$lastRef.$$action).toBeNull();
expect(snackBar.$$lastRef.$$config.duration).toBeUndefined();
}));
it('should report a failed activation', fakeAsync(() => {
activateUpdate(false);
expect(snackBar.$$lastRef.$$message).toContain('Update activation failed');
expect(snackBar.$$lastRef.$$action).toBe('Dismiss');
expect(snackBar.$$lastRef.$$config.duration).toBeGreaterThan(0);
}));
it('should dismiss the failed activation snack-bar when clicking on `Dismiss`', fakeAsync(() => {
activateUpdate(false);
expect(snackBar.$$lastRef.$$dismissed).toBe(false);
snackBar.$$lastRef.$$onActionSubj.next();
expect(snackBar.$$lastRef.$$dismissed).toBe(true);
}));
});
describe('#disable()', () => {
beforeEach(() => service.enable());
it('should dismiss open update notification', () => {
swUpdates.$$isUpdateAvailableSubj.next(true);
expect(snackBar.$$lastRef.$$message).toContain('ServiceWorker update available');
expect(snackBar.$$lastRef.$$dismissed).toBe(false);
service.disable();
expect(snackBar.$$lastRef.$$dismissed).toBe(true);
});
it('should dismiss open activation notification', fakeAsync(() => {
activateUpdate(true);
expect(snackBar.$$lastRef.$$message).toContain('Update activated successfully');
expect(snackBar.$$lastRef.$$dismissed).toBe(false);
service.disable();
expect(snackBar.$$lastRef.$$dismissed).toBe(true);
}));
it('should ignore further updates', () => {
service.disable();
swUpdates.$$isUpdateAvailableSubj.next(true);
expect(snackBar.$$lastRef).toBeUndefined();
});
it('should not ignore further updates if re-enabled', () => {
service.disable();
service.enable();
expect(snackBar.$$lastRef).toBeUndefined();
swUpdates.$$isUpdateAvailableSubj.next(true);
expect(snackBar.$$lastRef.$$message).toContain('ServiceWorker update available');
});
it('should not ignore pending updates if re-enabled', () => {
service.disable();
swUpdates.isUpdateAvailable = Observable.of(true);
expect(snackBar.$$lastRef).toBeUndefined();
service.enable();
expect(snackBar.$$lastRef.$$message).toContain('ServiceWorker update available');
});
});
});
// Mocks
class MockMdSnackBarRef {
$$afterDismissedSubj = new Subject();
$$onActionSubj = new Subject();
$$dismissed = false;
constructor(public $$message: string,
public $$action: string,
public $$config: MdSnackBarConfig) {}
afterDismissed() {
return this.$$afterDismissedSubj;
}
dismiss() {
this.$$dismissed = true;
}
onAction() {
return this.$$onActionSubj;
}
}
class MockMdSnackBar {
$$lastRef: MockMdSnackBarRef;
open(message: string, action: string = null, config: MdSnackBarConfig = {}): MockMdSnackBarRef {
if (this.$$lastRef && !this.$$lastRef.$$dismissed) {
this.$$lastRef.dismiss();
}
return this.$$lastRef = new MockMdSnackBarRef(message, action, config);
}
}

View File

@ -0,0 +1,85 @@
import { Injectable } from '@angular/core';
import { MdSnackBar, MdSnackBarConfig, MdSnackBarRef } from '@angular/material';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/filter';
import { SwUpdatesService } from './sw-updates.service';
/**
* SwUpdateNotificationsService
*
* @description
* Once enabled:
* 1. Subscribes to ServiceWorker updates and prompts the user to activate.
* 2. When the user confirms, it activates the update and notifies the user (upon activation success
* or failure).
* 3. Continues to listen for available ServiceWorker updates.
*
* @method
* `disable()` {() => void} - Dismiss any open notifications and stop listening for ServiceWorker
* updates.
*
* @method
* `enable()` {() => void} - Start listening for ServiceWorker updates.
*/
@Injectable()
export class SwUpdateNotificationsService {
private onDisable = new Subject();
private snackBars: MdSnackBarRef<any>[] = [];
private enabled = false;
constructor(private snackBarService: MdSnackBar, private swUpdates: SwUpdatesService) {
this.onDisable.subscribe(() => this.snackBars.forEach(sb => sb.dismiss()));
}
disable() {
if (this.enabled) {
this.enabled = false;
this.onDisable.next();
}
}
enable() {
if (!this.enabled) {
this.enabled = true;
this.swUpdates.isUpdateAvailable
.filter(v => v)
.takeUntil(this.onDisable)
.subscribe(() => this.notifyForUpdate());
}
}
private activateUpdate() {
this.swUpdates.activateUpdate().then(success => {
if (success) {
this.onActivateSuccess();
} else {
this.onActivateFailure();
}
});
}
private notifyForUpdate() {
this.openSnackBar('ServiceWorker update available.', 'Activate')
.onAction().subscribe(() => this.activateUpdate());
}
private onActivateFailure() {
const snackBar = this.openSnackBar('Update activation failed :(', 'Dismiss', {duration: 5000});
snackBar.onAction().subscribe(() => snackBar.dismiss());
}
private onActivateSuccess() {
this.openSnackBar('Update activated successfully! Reload the page to see the latest content.');
}
private openSnackBar(message: string, action?: string, config?: MdSnackBarConfig): MdSnackBarRef<any> {
const snackBar = this.snackBarService.open(message, action, config);
snackBar.afterDismissed().subscribe(() => this.snackBars = this.snackBars.filter(sb => sb !== snackBar));
this.snackBars.push(snackBar);
return snackBar;
}
}

View File

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { MdSnackBarModule } from '@angular/material';
import { ServiceWorkerModule } from '@angular/service-worker';
import { SwUpdateNotificationsService } from './sw-update-notifications.service';
import { SwUpdatesService } from './sw-updates.service';
@NgModule({
imports: [
MdSnackBarModule,
ServiceWorkerModule
],
providers: [
SwUpdateNotificationsService,
SwUpdatesService
]
})
export class SwUpdatesModule {}

View File

@ -0,0 +1,218 @@
import { ReflectiveInjector } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';
import { NgServiceWorker } from '@angular/service-worker';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/take';
import { SwUpdatesService } from './sw-updates.service';
describe('SwUpdatesService', () => {
let injector: ReflectiveInjector;
let service: SwUpdatesService;
let sw: MockNgServiceWorker;
let checkInterval: number;
// 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.
const setup = () => {
injector = ReflectiveInjector.resolveAndCreate([
{ provide: NgServiceWorker, useClass: MockNgServiceWorker },
SwUpdatesService
]);
service = injector.get(SwUpdatesService);
sw = injector.get(NgServiceWorker);
checkInterval = (service as any).checkInterval;
};
const tearDown = () => service.ngOnDestroy();
const run = specFn => () => {
setup();
specFn();
tearDown();
};
it('should create', run(() => {
expect(service).toBeTruthy();
}));
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 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);
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();
service.activateUpdate();
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 emit not emit a vew 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]);
})))
);
});
});
// Mocks
class MockNgServiceWorker {
$$activateUpdateSubj = new Subject<boolean>();
$$checkForUpdateSubj = new Subject<boolean>();
activateUpdate = jasmine.createSpy('MockNgServiceWorker.activateUpdate')
.and.callFake(() => this.$$activateUpdateSubj.take(1));
checkForUpdate = jasmine.createSpy('MockNgServiceWorker.checkForUpdate')
.and.callFake(() => this.$$checkForUpdateSubj.take(1));
}

View File

@ -0,0 +1,76 @@
import { Injectable, OnDestroy } from '@angular/core';
import { NgServiceWorker } from '@angular/service-worker';
import { Observable } from 'rxjs/Observable';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';
import 'rxjs/add/operator/concat';
import 'rxjs/add/operator/startWith';
import 'rxjs/add/operator/take';
import 'rxjs/add/operator/toPromise';
/**
* SwUpdatesService
*
* @description
* 1. Checks for available ServiceWorker updates once instantiated.
* 2. As long as there is no update available, re-checks every 6 hours.
* 3. As soon as an update is detected, it waits until the update is activated, then starts checking
* again (every 6 hours).
*
* @property
* `isUpdateAvailable` {Observable<boolean>} - Emit `true`/`false` to indicate updates being
* available or not. Remembers the last emitted value. Will only emit a new value if it is different
* than the last one.
*
* @method
* `activateUpdate()` {() => Promise<boolean>} - Activate the latest available update. The returned
* promise resolves to `true` if an update was activated successfully and `false` if the activation
* failed (e.g. if there was no update to activate).
*/
@Injectable()
export class SwUpdatesService implements OnDestroy {
private checkInterval = 1000 * 60 * 60 * 6; // 6 hours
private onDestroy = new Subject();
private checkForUpdateSubj = new Subject();
private isUpdateAvailableSubj = new ReplaySubject<boolean>(1);
isUpdateAvailable = this.isUpdateAvailableSubj.distinctUntilChanged();
constructor(private sw: NgServiceWorker) {
this.checkForUpdateSubj
.debounceTime(this.checkInterval)
.takeUntil(this.onDestroy)
.startWith(null)
.subscribe(() => this.checkForUpdate());
this.isUpdateAvailableSubj
.filter(v => !v)
.takeUntil(this.onDestroy)
.subscribe(() => this.checkForUpdateSubj.next());
}
ngOnDestroy() {
this.onDestroy.next();
}
activateUpdate(): Promise<boolean> {
return new Promise(resolve => {
this.sw.activateUpdate(null)
// Temp workaround for https://github.com/angular/mobile-toolkit/pull/137.
// TODO (gkalpak): Remove once #137 is fixed.
.concat(Observable.of(false)).take(1)
.do(() => this.isUpdateAvailableSubj.next(false))
.subscribe(resolve);
});
}
private checkForUpdate() {
this.sw.checkForUpdate()
// Temp workaround for https://github.com/angular/mobile-toolkit/pull/137.
// TODO (gkalpak): Remove once #137 is fixed.
.concat(Observable.of(false)).take(1)
.subscribe(v => this.isUpdateAvailableSubj.next(v));
}
}

View File

@ -0,0 +1,4 @@
export class MockSwUpdateNotificationsService {
enable = jasmine.createSpy('MockSwUpdateNotificationsService.enable');
disable = jasmine.createSpy('MockSwUpdateNotificationsService.disable');
}

View File

@ -0,0 +1,20 @@
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/distinctUntilChanged';
import 'rxjs/add/operator/take';
export class MockSwUpdatesService {
$$activateUpdateSubj = new Subject<boolean>();
$$isUpdateAvailableSubj = new Subject<boolean>();
isUpdateAvailable = this.$$isUpdateAvailableSubj.distinctUntilChanged();
activateUpdate(): Promise<boolean> {
return new Promise(resolve => {
this.$$activateUpdateSubj
// Better simulate what actually happens with the real ServiceWorker.
.take(1)
.do(() => this.$$isUpdateAvailableSubj.next(false))
.subscribe(resolve);
});
}
}