diff --git a/aio/e2e/app.e2e-spec.ts b/aio/e2e/app.e2e-spec.ts index 032bab0f27..4c3e3bf40f 100644 --- a/aio/e2e/app.e2e-spec.ts +++ b/aio/e2e/app.e2e-spec.ts @@ -1,3 +1,4 @@ +import { browser, element, by, promise } from 'protractor'; import { SitePage } from './app.po'; describe('site App', function() { @@ -15,4 +16,31 @@ describe('site App', function() { }); it('should convert a doc with a code-example'); + + describe('google analytics', () => { + beforeEach(done => page.gaReady.then(done)); + + it('should call ga', done => { + page.ga() + .then(calls => { + expect(calls.length).toBeGreaterThan(2, 'ga calls'); + done(); + }); + }); + + it('should call ga with initial URL', done => { + let path: string; + + page.locationPath() + .then(p => path = p) + .then(() => page.ga().then(calls => { + expect(calls.length).toBeGreaterThan(2, 'ga calls'); + expect(calls[1]).toEqual(['set', 'page', path]); + done(); + })); + }); + + // Todo: add test to confirm tracking URL when navigate. + }); + }); diff --git a/aio/e2e/app.po.ts b/aio/e2e/app.po.ts index 54dc8126c5..1fcf251346 100644 --- a/aio/e2e/app.po.ts +++ b/aio/e2e/app.po.ts @@ -1,18 +1,46 @@ -import { browser, element, by } from 'protractor'; +import { browser, element, by, promise } from 'protractor'; export class SitePage { - links = element.all(by.css('md-toolbar a')); docViewer = element(by.css('aio-doc-viewer')); codeExample = element.all(by.css('aio-doc-viewer pre > code')); featureLink = element(by.css('md-toolbar a[href="features"]')); + gaReady: promise.Promise; + ga = () => browser.executeScript('return window["gaCalls"]') as promise.Promise; + locationPath = () => browser.executeScript('return document.location.pathname') as promise.Promise; navigateTo() { - return browser.get('/'); + return browser.get('/').then(_ => this.replaceGa(_)); } getDocViewerText() { return this.docViewer.getText(); } + /** + * Replace the ambient Google Analytics tracker with homebrew spy + * don't send commands to GA during e2e testing! + * @param _ - forward's anything passed in + */ + private replaceGa(_: any) { + + this.gaReady = browser.driver.executeScript(() => { + // Give ga() a "ready" callback: + // https://developers.google.com/analytics/devguides/collection/analyticsjs/command-queue-reference + window['ga'](() => { + window['gaCalls'] = []; + window['ga'] = function() { window['gaCalls'].push(arguments); }; + }); + + }) + .then(() => { + // wait for GaService to start using window.ga after analytics lib loads. + const d = promise.defer(); + setTimeout(() => d.fulfill(), 1000); // GaService.initializeDelay + return d.promise; + }); + + return _; + } } + diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index f477958bbd..e968a88827 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -2,6 +2,7 @@ import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing' import { APP_BASE_HREF } from '@angular/common'; import { AppComponent } from './app.component'; import { AppModule } from './app.module'; +import { GaService } from 'app/shared/ga.service'; import { SearchService } from 'app/search/search.service'; import { MockSearchService } from 'testing/search.service'; @@ -14,7 +15,8 @@ describe('AppComponent', () => { imports: [ AppModule ], providers: [ { provide: APP_BASE_HREF, useValue: '/' }, - { provide: SearchService, useClass: MockSearchService } + { provide: SearchService, useClass: MockSearchService }, + { provide: GaService, useClass: TestGaService } ] }); TestBed.compileComponents(); @@ -29,6 +31,18 @@ describe('AppComponent', () => { expect(component).toBeDefined(); }); + describe('google analytics', () => { + it('should call gaService.locationChanged with initial URL', () => { + const url = window.location.pathname.substr(1); // strip leading '/' + const { locationChanged } = TestBed.get(GaService) as TestGaService; + expect(locationChanged.calls.count()).toBe(1, 'gaService.locationChanged'); + const args = locationChanged.calls.first().args; + expect(args[0]).toBe(url); + }); + + // Todo: add test to confirm tracking URL when navigate. + }); + describe('isHamburgerVisible', () => { }); @@ -57,3 +71,7 @@ describe('AppComponent', () => { })); }); }); + +class TestGaService { + locationChanged = jasmine.createSpy('locationChanged'); +} diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index f21a1cd562..f021ed9b3f 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -1,5 +1,8 @@ import { Component, ViewChild, OnInit } from '@angular/core'; import { Observable } from 'rxjs/Observable'; + +import { GaService } from 'app/shared/ga.service'; +import { LocationService } from 'app/shared/location.service'; import { DocumentService, DocumentContents } from 'app/documents/document.service'; import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service'; import { SearchService } from 'app/search/search.service'; @@ -20,8 +23,15 @@ export class AppComponent implements OnInit { navigationViews: Observable; selectedNodes: Observable; - constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) { + constructor( + documentService: DocumentService, + gaService: GaService, + locationService: LocationService, + navigationService: NavigationService, + private searchService: SearchService) { + this.currentDocument = documentService.currentDocument; + locationService.currentUrl.subscribe(url => gaService.locationChanged(url)); this.navigationViews = navigationService.navigationViews; this.selectedNodes = navigationService.selectedNodes; } diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 3037a46534..c2a360ba27 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -19,6 +19,7 @@ import { AppComponent } from 'app/app.component'; import { ApiService } from 'app/embedded/api/api.service'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; import { embeddedComponents, EmbeddedComponents } from 'app/embedded'; +import { GaService } from 'app/shared/ga.service'; import { Logger } from 'app/shared/logger.service'; import { LocationService } from 'app/shared/location.service'; import { NavigationService } from 'app/navigation/navigation.service'; @@ -55,6 +56,7 @@ import { SearchBoxComponent } from './search/search-box/search-box.component'; providers: [ ApiService, EmbeddedComponents, + GaService, Logger, Location, { provide: LocationStrategy, useClass: PathLocationStrategy }, diff --git a/aio/src/app/documents/document.service.ts b/aio/src/app/documents/document.service.ts index 00e1010385..079e676378 100644 --- a/aio/src/app/documents/document.service.ts +++ b/aio/src/app/documents/document.service.ts @@ -5,11 +5,12 @@ import { Observable } from 'rxjs/Observable'; import { AsyncSubject } from 'rxjs/AsyncSubject'; import 'rxjs/add/operator/switchMap'; -import { LocationService } from 'app/shared/location.service'; -import { Logger } from 'app/shared/logger.service'; import { DocumentContents } from './document-contents'; export { DocumentContents } from './document-contents'; +import { LocationService } from 'app/shared/location.service'; +import { Logger } from 'app/shared/logger.service'; + const FILE_NOT_FOUND_URL = 'file-not-found'; @Injectable() @@ -20,7 +21,10 @@ export class DocumentService { currentDocument: Observable; - constructor(private logger: Logger, private http: Http, location: LocationService) { + constructor( + private logger: Logger, + private http: Http, + location: LocationService) { // Whenever the URL changes we try to get the appropriate doc this.currentDocument = location.currentUrl.switchMap(url => this.getDocument(url)); } diff --git a/aio/src/app/shared/ga.service.spec.ts b/aio/src/app/shared/ga.service.spec.ts new file mode 100644 index 0000000000..c654221316 --- /dev/null +++ b/aio/src/app/shared/ga.service.spec.ts @@ -0,0 +1,107 @@ +import { ReflectiveInjector } from '@angular/core'; +import { fakeAsync, tick } from '@angular/core/testing'; + +import { GaService } from 'app/shared/ga.service'; +import { Logger } from 'app/shared/logger.service'; + +describe('GaService', () => { + let gaSpy: jasmine.Spy; + let injector: ReflectiveInjector; + + + beforeEach(() => { + injector = ReflectiveInjector.resolveAndCreate([ + GaService, + { provide: Logger, useClass: TestLogger } + ]); + }); + + describe('with ambient GA', () => { + let gaService: GaService; + + beforeEach(fakeAsync(() => { + this.winGa = window['ga']; // remember current GA tracker just in case + + // Replace Google Analytics tracker with spy after calling "ga ready" callback + window['ga'] = (fn: Function) => { + window['ga'] = gaSpy = jasmine.createSpy('ga'); + fn(); + tick(GaService.initializeDelay); // see GaService#initializeGa + }; + gaService = injector.get(GaService); + })); + + afterEach(() => { + window['ga'] = this.winGa; + }); + + it('should initialize ga with "create" when constructed', () => { + const first = gaSpy.calls.first().args; + expect(first[0]).toBe('create'); + }); + + describe('#sendPage(url)', () => { + it('should set page to url w/ leading slash', () => { + gaService.sendPage('testUrl'); + const args = gaSpy.calls.all()[1].args; + expect(args).toEqual(['set', 'page', '/testUrl']); + }); + + it('should send "pageview" ', () => { + gaService.sendPage('testUrl'); + const args = gaSpy.calls.all()[2].args; + expect(args).toEqual(['send', 'pageview']); + }); + + it('should not send twice with same URL, back-to-back', () => { + gaService.sendPage('testUrl'); + const count1 = gaSpy.calls.count(); + + gaService.sendPage('testUrl'); + const count2 = gaSpy.calls.count(); + expect(count2).toEqual(count1); + }); + + it('should send same URL twice when other intervening URL', () => { + gaService.sendPage('testUrl'); + const count1 = gaSpy.calls.count(); + + gaService.sendPage('testUrl2'); + const count2 = gaSpy.calls.count(); + expect(count2).toBeGreaterThan(count1, 'testUrl2 was sent'); + + gaService.sendPage('testUrl'); + const count3 = gaSpy.calls.count(); + expect(count3).toBeGreaterThan(count1, 'testUrl was sent 2nd time'); + }); + }); + + describe('#locationChanged(url)', () => { + it('should send page to url w/ leading slash', () => { + gaService.locationChanged('testUrl'); + let args = gaSpy.calls.all()[1].args; + expect(args).toEqual(['set', 'page', '/testUrl']); + args = gaSpy.calls.all()[2].args; + expect(args).toEqual(['send', 'pageview']); + }); + }); + }); + + describe('when no ambient GA', () => { + let gaService: GaService; + let logger: TestLogger; + + it('should log with "create" when constructed', () => { + gaService = injector.get(GaService); + logger = injector.get(Logger); + expect(logger.log.calls.count()).toBe(1, 'logger.log should be called'); + const first = logger.log.calls.first().args; + expect(first[0]).toBe('ga:'); + expect(first[1][0]).toBe('create'); // first[1] is the array of args to ga() + }); + }); +}); + +class TestLogger { + log = jasmine.createSpy('log'); +} diff --git a/aio/src/app/shared/ga.service.ts b/aio/src/app/shared/ga.service.ts new file mode 100644 index 0000000000..6296614de0 --- /dev/null +++ b/aio/src/app/shared/ga.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; + +import { environment } from '../../environments/environment'; +import { Logger } from 'app/shared/logger.service'; + +@Injectable() +/** + * Google Analytics Service - captures app behaviours and sends them to Google Analytics (GA). + * Presupposes that GA script has been loaded from a script on the host web page. + * Associates data with a GA "property" from the environment (`gaId`). + */ +export class GaService { + // ms to wait before acquiring window.ga after analytics library loads + // empirically determined to allow time for e2e test setup + static initializeDelay = 1000; + + private previousUrl: string; + private ga: (...rest: any[]) => void; + + constructor(private logger: Logger) { + this.initializeGa(); + this.ga('create', environment['gaId'] , 'auto'); + } + + locationChanged(url: string) { + this.sendPage(url); + } + + sendPage(url: string) { + if (url === this.previousUrl) { return; } + this.previousUrl = url; + this.ga('set', 'page', '/' + url); + this.ga('send', 'pageview'); + } + + // These gyrations are necessary to make the service e2e testable + // and to disable ga tracking during e2e tests. + private initializeGa() { + const ga = window['ga']; + if (ga) { + // Queue commands until GA analytics script has loaded. + const gaQueue: any[][] = []; + this.ga = (...rest: any[]) => { gaQueue.push(rest); }; + + // Then send queued commands to either real or e2e test ga(); + // after waiting to allow possible e2e test to replace global ga function + ga(() => setTimeout(() => { + // this.logger.log('GA fn:', window['ga'].toString()); + this.ga = window['ga']; + gaQueue.forEach((command) => this.ga.apply(null, command)); + }, GaService.initializeDelay)); + + } else { + // delegate `ga` calls to the logger if no ga installed + this.ga = (...rest: any[]) => { this.logger.log('ga:', rest); }; + } + } + +} diff --git a/aio/src/app/shared/location.service.ts b/aio/src/app/shared/location.service.ts index 1accc3138f..f2ebb22384 100644 --- a/aio/src/app/shared/location.service.ts +++ b/aio/src/app/shared/location.service.ts @@ -1,18 +1,20 @@ import { Injectable } from '@angular/core'; import { Location, PlatformLocation } from '@angular/common'; import { Observable } from 'rxjs/Observable'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { ReplaySubject } from 'rxjs/ReplaySubject'; @Injectable() export class LocationService { - private urlSubject: BehaviorSubject; - get currentUrl() { return this.urlSubject.asObservable(); } + private urlSubject = new ReplaySubject(1); + currentUrl = this.urlSubject.asObservable(); - constructor(private location: Location, private platformLocation: PlatformLocation) { + constructor( + private location: Location, + private platformLocation: PlatformLocation) { const initialUrl = this.stripLeadingSlashes(location.path(true)); - this.urlSubject = new BehaviorSubject(initialUrl); + this.urlSubject.next(initialUrl); this.location.subscribe(state => { const url = this.stripLeadingSlashes(state.url); diff --git a/aio/src/environments/environment.prod.ts b/aio/src/environments/environment.prod.ts index 3612073bc3..c81ba25ab9 100644 --- a/aio/src/environments/environment.prod.ts +++ b/aio/src/environments/environment.prod.ts @@ -1,3 +1,4 @@ export const environment = { + gaId: 'UA-8594346-26', // TODO: this is for the staging site; reset to correct account production: true }; diff --git a/aio/src/environments/environment.ts b/aio/src/environments/environment.ts index 00313f1664..69c1f7c8bd 100644 --- a/aio/src/environments/environment.ts +++ b/aio/src/environments/environment.ts @@ -4,5 +4,6 @@ // The list of which env maps to which file can be found in `angular-cli.json`. export const environment = { + gaId: 'UA-8594346-26', // Staging site production: false }; diff --git a/aio/src/index.html b/aio/src/index.html index d0c27a795b..ec9889ba8c 100644 --- a/aio/src/index.html +++ b/aio/src/index.html @@ -22,6 +22,16 @@ + + + + +