feat(aio): enable activating available ServiceWorker updates (#15561)
This commit is contained in:
parent
67719f2185
commit
3ccbe28d9f
|
@ -17,6 +17,8 @@ import { LocationService } from 'app/shared/location.service';
|
||||||
import { MockLocationService } from 'testing/location.service';
|
import { MockLocationService } from 'testing/location.service';
|
||||||
import { Logger } from 'app/shared/logger.service';
|
import { Logger } from 'app/shared/logger.service';
|
||||||
import { MockLogger } from 'testing/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', () => {
|
describe('AppComponent', () => {
|
||||||
let component: AppComponent;
|
let component: AppComponent;
|
||||||
|
@ -32,7 +34,8 @@ describe('AppComponent', () => {
|
||||||
{ provide: Http, useClass: TestHttp },
|
{ provide: Http, useClass: TestHttp },
|
||||||
{ provide: LocationService, useFactory: () => new MockLocationService(initialUrl) },
|
{ provide: LocationService, useFactory: () => new MockLocationService(initialUrl) },
|
||||||
{ provide: Logger, useClass: MockLogger },
|
{ provide: Logger, useClass: MockLogger },
|
||||||
{ provide: SearchService, useClass: MockSearchService }
|
{ provide: SearchService, useClass: MockSearchService },
|
||||||
|
{ provide: SwUpdateNotificationsService, useClass: MockSwUpdateNotificationsService },
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
TestBed.compileComponents();
|
TestBed.compileComponents();
|
||||||
|
@ -48,6 +51,13 @@ describe('AppComponent', () => {
|
||||||
expect(component).toBeDefined();
|
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', () => {
|
describe('is Hamburger Visible', () => {
|
||||||
console.log('PENDING: AppComponent');
|
console.log('PENDING: AppComponent');
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||||
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
||||||
|
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
|
||||||
|
|
||||||
|
|
||||||
const sideNavView = 'SideNav';
|
const sideNavView = 'SideNav';
|
||||||
|
|
||||||
|
@ -54,7 +56,8 @@ export class AppComponent implements OnInit {
|
||||||
private autoScrollService: AutoScrollService,
|
private autoScrollService: AutoScrollService,
|
||||||
private documentService: DocumentService,
|
private documentService: DocumentService,
|
||||||
private locationService: LocationService,
|
private locationService: LocationService,
|
||||||
private navigationService: NavigationService
|
private navigationService: NavigationService,
|
||||||
|
private swUpdateNotifications: SwUpdateNotificationsService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
@ -83,6 +86,8 @@ export class AppComponent implements OnInit {
|
||||||
|
|
||||||
this.navigationService.versionInfo.subscribe( vi => this.versionInfo = vi );
|
this.navigationService.versionInfo.subscribe( vi => this.versionInfo = vi );
|
||||||
|
|
||||||
|
this.swUpdateNotifications.enable();
|
||||||
|
|
||||||
this.onResize(window.innerWidth);
|
this.onResize(window.innerWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,8 @@ import { Platform } from '@angular/material/core';
|
||||||
// crashes with "missing first" operator when SideNav.mode is "over"
|
// crashes with "missing first" operator when SideNav.mode is "over"
|
||||||
import 'rxjs/add/operator/first';
|
import 'rxjs/add/operator/first';
|
||||||
|
|
||||||
|
import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module';
|
||||||
|
|
||||||
import { AppComponent } from 'app/app.component';
|
import { AppComponent } from 'app/app.component';
|
||||||
import { ApiService } from 'app/embedded/api/api.service';
|
import { ApiService } from 'app/embedded/api/api.service';
|
||||||
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||||
|
@ -46,7 +48,8 @@ import { AutoScrollService } from 'app/shared/auto-scroll.service';
|
||||||
MdInputModule,
|
MdInputModule,
|
||||||
MdToolbarModule,
|
MdToolbarModule,
|
||||||
MdSidenavModule,
|
MdSidenavModule,
|
||||||
MdTabsModule
|
MdTabsModule,
|
||||||
|
SwUpdatesModule
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
AppComponent,
|
AppComponent,
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {}
|
|
@ -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));
|
||||||
|
}
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export class MockSwUpdateNotificationsService {
|
||||||
|
enable = jasmine.createSpy('MockSwUpdateNotificationsService.enable');
|
||||||
|
disable = jasmine.createSpy('MockSwUpdateNotificationsService.disable');
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue