diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index 9e65735bfb..a996da143a 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -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'); }); diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 81ac6abef3..bdf8aa63e0 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -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); } diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 0a653204f7..f53de92cbf 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -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, diff --git a/aio/src/app/sw-updates/sw-update-notifications.service.spec.ts b/aio/src/app/sw-updates/sw-update-notifications.service.spec.ts new file mode 100644 index 0000000000..5f1ec50d66 --- /dev/null +++ b/aio/src/app/sw-updates/sw-update-notifications.service.spec.ts @@ -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); + } +} diff --git a/aio/src/app/sw-updates/sw-update-notifications.service.ts b/aio/src/app/sw-updates/sw-update-notifications.service.ts new file mode 100644 index 0000000000..d321f1f5d3 --- /dev/null +++ b/aio/src/app/sw-updates/sw-update-notifications.service.ts @@ -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[] = []; + 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 { + 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; + } +} diff --git a/aio/src/app/sw-updates/sw-updates.module.ts b/aio/src/app/sw-updates/sw-updates.module.ts new file mode 100644 index 0000000000..3e0ee78c93 --- /dev/null +++ b/aio/src/app/sw-updates/sw-updates.module.ts @@ -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 {} diff --git a/aio/src/app/sw-updates/sw-updates.service.spec.ts b/aio/src/app/sw-updates/sw-updates.service.spec.ts new file mode 100644 index 0000000000..6285ca64ee --- /dev/null +++ b/aio/src/app/sw-updates/sw-updates.service.spec.ts @@ -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(); + $$checkForUpdateSubj = new Subject(); + + activateUpdate = jasmine.createSpy('MockNgServiceWorker.activateUpdate') + .and.callFake(() => this.$$activateUpdateSubj.take(1)); + + checkForUpdate = jasmine.createSpy('MockNgServiceWorker.checkForUpdate') + .and.callFake(() => this.$$checkForUpdateSubj.take(1)); +} diff --git a/aio/src/app/sw-updates/sw-updates.service.ts b/aio/src/app/sw-updates/sw-updates.service.ts new file mode 100644 index 0000000000..60a9ef1fcb --- /dev/null +++ b/aio/src/app/sw-updates/sw-updates.service.ts @@ -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} - 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} - 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(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 { + 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)); + } +} diff --git a/aio/src/testing/sw-update-notifications.service.ts b/aio/src/testing/sw-update-notifications.service.ts new file mode 100644 index 0000000000..30c0cefa1d --- /dev/null +++ b/aio/src/testing/sw-update-notifications.service.ts @@ -0,0 +1,4 @@ +export class MockSwUpdateNotificationsService { + enable = jasmine.createSpy('MockSwUpdateNotificationsService.enable'); + disable = jasmine.createSpy('MockSwUpdateNotificationsService.disable'); +} diff --git a/aio/src/testing/sw-updates.service.ts b/aio/src/testing/sw-updates.service.ts new file mode 100644 index 0000000000..2553674d0f --- /dev/null +++ b/aio/src/testing/sw-updates.service.ts @@ -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(); + $$isUpdateAvailableSubj = new Subject(); + isUpdateAvailable = this.$$isUpdateAvailableSubj.distinctUntilChanged(); + + activateUpdate(): Promise { + return new Promise(resolve => { + this.$$activateUpdateSubj + // Better simulate what actually happens with the real ServiceWorker. + .take(1) + .do(() => this.$$isUpdateAvailableSubj.next(false)) + .subscribe(resolve); + }); + } +}