diff --git a/angular.json b/angular.json index a3d5098..183e21b 100644 --- a/angular.json +++ b/angular.json @@ -27,7 +27,8 @@ "options": { "main": "projects/ngx-matomo-client/test.ts", "tsConfig": "projects/ngx-matomo-client/tsconfig.spec.json", - "karmaConfig": "projects/ngx-matomo-client/karma.conf.js" + "karmaConfig": "projects/ngx-matomo-client/karma.conf.js", + "codeCoverageExclude": ["projects/ngx-matomo-client/core/testing/**"] } }, "lint": { diff --git a/projects/demo/src/app/app.component.spec.ts b/projects/demo/src/app/app.component.spec.ts index f3ee5d2..dc8632c 100644 --- a/projects/demo/src/app/app.component.spec.ts +++ b/projects/demo/src/app/app.component.spec.ts @@ -1,7 +1,7 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { MatomoConfiguration, MatomoRouterModule, MatomoModule } from 'ngx-matomo-client'; +import { MatomoModule, MatomoRouterModule } from 'ngx-matomo-client'; import { AppComponent } from './app.component'; describe('AppComponent', () => { @@ -12,8 +12,8 @@ describe('AppComponent', () => { MatomoModule.forRoot({ trackerUrl: '', siteId: '', - } as MatomoConfiguration), - MatomoRouterModule, + }), + MatomoRouterModule.forRoot(), ], declarations: [AppComponent], schemas: [NO_ERRORS_SCHEMA], diff --git a/projects/ngx-matomo-client/core/directives/matomo-opt-out-form.component.spec.ts b/projects/ngx-matomo-client/core/directives/matomo-opt-out-form.component.spec.ts index 2753f92..a7ebe70 100644 --- a/projects/ngx-matomo-client/core/directives/matomo-opt-out-form.component.spec.ts +++ b/projects/ngx-matomo-client/core/directives/matomo-opt-out-form.component.spec.ts @@ -1,14 +1,13 @@ import { Component, LOCALE_ID } from '@angular/core'; import { fakeAsync, flush, TestBed } from '@angular/core/testing'; import { By, DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { provideMatomo } from '../providers'; +import { provideTestingTracker } from '../testing/testing-tracker'; import { ASYNC_INTERNAL_MATOMO_CONFIGURATION, INTERNAL_MATOMO_CONFIGURATION, InternalMatomoConfiguration, - MATOMO_CONFIGURATION, - MatomoConfiguration, } from '../tracker/configuration'; -import { MatomoInitializerService } from '../tracker/matomo-initializer.service'; import { MatomoOptOutFormComponent } from './matomo-opt-out-form.component'; @Component({ @@ -102,18 +101,14 @@ describe('MatomoOptOutFormComponent', () => { HostWithoutLocaleComponent, ], providers: [ - { - provide: MATOMO_CONFIGURATION, - useValue: { siteId: 1, trackerUrl: 'http://localhost' } as MatomoConfiguration, - }, + provideMatomo({ siteId: 1, trackerUrl: 'http://localhost' }), + provideTestingTracker(), { provide: LOCALE_ID, useValue: 'en', }, ], }).compileComponents(); - - TestBed.inject(MatomoInitializerService).initialize(); }); it('should create', async () => { diff --git a/projects/ngx-matomo-client/core/private-api.ts b/projects/ngx-matomo-client/core/private-api.ts index 281b31e..bdb105e 100644 --- a/projects/ngx-matomo-client/core/private-api.ts +++ b/projects/ngx-matomo-client/core/private-api.ts @@ -12,4 +12,8 @@ export { isAutoConfigurationMode as ɵisAutoConfigurationMode, } from './tracker/configuration'; export { InternalMatomoTracker as ɵInternalMatomoTracker } from './tracker/internal-matomo-tracker.service'; +export { + provideTestingTracker as ɵprovideTestingTracker, + MatomoTestingTracker as ɵMatomoTestingTracker, +} from './testing/testing-tracker'; export { createMatomoFeature as ɵcreateMatomoFeature } from './providers'; diff --git a/projects/ngx-matomo-client/core/providers.spec.ts b/projects/ngx-matomo-client/core/providers.spec.ts index 2ed7016..6c3ccb0 100644 --- a/projects/ngx-matomo-client/core/providers.spec.ts +++ b/projects/ngx-matomo-client/core/providers.spec.ts @@ -21,11 +21,11 @@ describe('providers', () => { const config: MatomoConfiguration = { trackerUrl: 'my-tracker', siteId: 42 }; await setUp([ + provideMatomo(config), { provide: MatomoInitializerService, useValue: fakeInitializer, }, - provideMatomo(config), ]); expect(TestBed.inject(MatomoTracker)).toEqual(jasmine.any(MatomoTracker)); @@ -40,6 +40,7 @@ describe('providers', () => { const trackerUrlToken = new InjectionToken('trackerUrl'); await setUp([ + provideMatomo(() => ({ trackerUrl: TestBed.inject(trackerUrlToken), siteId: 42 })), { provide: MatomoInitializerService, useValue: fakeInitializer, @@ -48,7 +49,6 @@ describe('providers', () => { provide: trackerUrlToken, useValue: trackerUrl, }, - provideMatomo(() => ({ trackerUrl: TestBed.inject(trackerUrlToken), siteId: 42 })), ]); expect(TestBed.inject(MatomoTracker)).toEqual(jasmine.any(MatomoTracker)); diff --git a/projects/ngx-matomo-client/core/providers.ts b/projects/ngx-matomo-client/core/providers.ts index bed7dc7..9079e7d 100644 --- a/projects/ngx-matomo-client/core/providers.ts +++ b/projects/ngx-matomo-client/core/providers.ts @@ -5,9 +5,26 @@ import { makeEnvironmentProviders, Provider, } from '@angular/core'; -import { MATOMO_CONFIGURATION, MatomoConfiguration } from './tracker/configuration'; -import { MatomoInitializerService } from './tracker/matomo-initializer.service'; +import { + ASYNC_INTERNAL_MATOMO_CONFIGURATION, + createDeferredInternalMatomoConfiguration, + createInternalMatomoConfiguration, + DEFERRED_INTERNAL_MATOMO_CONFIGURATION, + INTERNAL_MATOMO_CONFIGURATION, + MATOMO_CONFIGURATION, + MatomoConfiguration, +} from './tracker/configuration'; +import { + createInternalMatomoTracker, + InternalMatomoTracker, +} from './tracker/internal-matomo-tracker.service'; +import { + createMatomoInitializer, + MatomoInitializerService, +} from './tracker/matomo-initializer.service'; +import { MatomoTracker } from './tracker/matomo-tracker.service'; import { MATOMO_SCRIPT_FACTORY, MatomoScriptFactory } from './tracker/script-factory'; +import { ScriptInjector } from './utils/script-injector'; const PRIVATE_MATOMO_PROVIDERS = Symbol('MATOMO_PROVIDERS'); const PRIVATE_MATOMO_CHECKS = Symbol('MATOMO_CHECKS'); @@ -74,6 +91,28 @@ export function provideMatomo( ...features: MatomoFeature[] ): EnvironmentProviders { const providers: Provider[] = [ + MatomoTracker, + ScriptInjector, + { + provide: InternalMatomoTracker, + useFactory: createInternalMatomoTracker, + }, + { + provide: MatomoInitializerService, + useFactory: createMatomoInitializer, + }, + { + provide: INTERNAL_MATOMO_CONFIGURATION, + useFactory: createInternalMatomoConfiguration, + }, + { + provide: DEFERRED_INTERNAL_MATOMO_CONFIGURATION, + useFactory: createDeferredInternalMatomoConfiguration, + }, + { + provide: ASYNC_INTERNAL_MATOMO_CONFIGURATION, + useFactory: () => inject(DEFERRED_INTERNAL_MATOMO_CONFIGURATION).configuration, + }, { provide: ENVIRONMENT_INITIALIZER, multi: true, diff --git a/projects/ngx-matomo-client/core/testing/testing-tracker.ts b/projects/ngx-matomo-client/core/testing/testing-tracker.ts new file mode 100644 index 0000000..3b74fff --- /dev/null +++ b/projects/ngx-matomo-client/core/testing/testing-tracker.ts @@ -0,0 +1,59 @@ +import { ApplicationInitStatus, inject, Injectable, Provider } from '@angular/core'; +import { + InternalMatomoTracker, + InternalMatomoTrackerType, +} from '../tracker/internal-matomo-tracker.service'; +import { PrefixedType } from '../utils/types'; + +export function provideTestingTracker(): Provider[] { + return [ + MatomoTestingTracker, + { + provide: InternalMatomoTracker, + useExisting: MatomoTestingTracker, + }, + ]; +} + +@Injectable() +export class MatomoTestingTracker + implements InternalMatomoTrackerType +{ + private readonly initStatus = inject(ApplicationInitStatus); + + /** Get list of all calls until initialization */ + callsOnInit: unknown[][] = []; + /** Get list of all calls after initialization */ + callsAfterInit: unknown[][] = []; + + /** Get a copy of all calls since application startup */ + get calls(): unknown[] { + return [...this.callsOnInit, ...this.callsAfterInit]; + } + + countCallsAfterInit(command: string): number { + return this.callsAfterInit.filter(call => call[0] === command).length; + } + + reset() { + this.callsOnInit = []; + this.callsAfterInit = []; + } + + /** Asynchronously call provided method name on matomo tracker instance */ + async get>(_: K): Promise { + return Promise.reject('MatomoTracker is disabled'); + } + + push(arg: unknown[]): void { + if (this.initStatus.done) { + this.callsAfterInit.push(arg); + } else { + this.callsOnInit.push(arg); + } + } + + async pushFn(_: (matomo: PrefixedType) => T): Promise { + return Promise.reject('MatomoTracker is disabled'); + } +} diff --git a/projects/ngx-matomo-client/core/tracker/configuration.ts b/projects/ngx-matomo-client/core/tracker/configuration.ts index 5cdc611..bea7e2a 100644 --- a/projects/ngx-matomo-client/core/tracker/configuration.ts +++ b/projects/ngx-matomo-client/core/tracker/configuration.ts @@ -20,59 +20,53 @@ export const MATOMO_CONFIGURATION = new InjectionToken('MAT */ export const INTERNAL_MATOMO_CONFIGURATION = new InjectionToken( 'INTERNAL_MATOMO_CONFIGURATION', - { - factory(): InternalMatomoConfiguration { - const { mode, requireConsent, ...restConfig } = requireNonNull( - inject(MATOMO_CONFIGURATION, { optional: true }), - CONFIG_NOT_FOUND, - ); - - return { - mode: mode ? coerceInitializationMode(mode) : undefined, - disabled: false, - enableLinkTracking: true, - trackAppInitialLoad: !inject(MATOMO_ROUTER_ENABLED), - requireConsent: requireConsent ? coerceConsentRequirement(requireConsent) : 'none', - enableJSErrorTracking: false, - runOutsideAngularZone: false, - disableCampaignParameters: false, - acceptDoNotTrack: false, - ...restConfig, - }; - }, - }, ); +export function createInternalMatomoConfiguration(): InternalMatomoConfiguration { + const { mode, requireConsent, ...restConfig } = requireNonNull( + inject(MATOMO_CONFIGURATION, { optional: true }), + CONFIG_NOT_FOUND, + ); + + return { + mode: mode ? coerceInitializationMode(mode) : undefined, + disabled: false, + enableLinkTracking: true, + trackAppInitialLoad: !inject(MATOMO_ROUTER_ENABLED), + requireConsent: requireConsent ? coerceConsentRequirement(requireConsent) : 'none', + enableJSErrorTracking: false, + runOutsideAngularZone: false, + disableCampaignParameters: false, + acceptDoNotTrack: false, + ...restConfig, + }; +} + /** * For internal use only. Injection token for deferred {@link InternalMatomoConfiguration}. * */ export const DEFERRED_INTERNAL_MATOMO_CONFIGURATION = - new InjectionToken( - 'DEFERRED_INTERNAL_MATOMO_CONFIGURATION', - { - factory: () => { - const base = inject(INTERNAL_MATOMO_CONFIGURATION); - let resolveFn: ((configuration: InternalMatomoConfiguration) => void) | undefined; - const configuration = new Promise( - resolve => (resolveFn = resolve), - ); - - return { - configuration, - markReady(configuration) { - requireNonNull( - resolveFn, - 'resolveFn', - )({ - ...base, - ...configuration, - } as InternalMatomoConfiguration); - }, - }; - }, + new InjectionToken('DEFERRED_INTERNAL_MATOMO_CONFIGURATION'); + +export function createDeferredInternalMatomoConfiguration(): DeferredInternalMatomoConfiguration { + const base = inject(INTERNAL_MATOMO_CONFIGURATION); + let resolveFn: ((configuration: InternalMatomoConfiguration) => void) | undefined; + const configuration = new Promise(resolve => (resolveFn = resolve)); + + return { + configuration, + markReady(configuration) { + requireNonNull( + resolveFn, + 'resolveFn', + )({ + ...base, + ...configuration, + }); }, - ); + }; +} /** * For internal use only. Injection token for fully loaded async {@link InternalMatomoConfiguration}. @@ -80,9 +74,7 @@ export const DEFERRED_INTERNAL_MATOMO_CONFIGURATION = */ export const ASYNC_INTERNAL_MATOMO_CONFIGURATION = new InjectionToken< Promise ->('ASYNC_INTERNAL_MATOMO_CONFIGURATION', { - factory: () => inject(DEFERRED_INTERNAL_MATOMO_CONFIGURATION).configuration, -}); +>('ASYNC_INTERNAL_MATOMO_CONFIGURATION'); /** * For internal use only. Module configuration merged with default values. @@ -97,7 +89,7 @@ export type InternalMatomoConfiguration = Omit; - markReady(configuration: AutoMatomoConfiguration<'auto' | 'deferred'>): void; + markReady(configuration: InternalMatomoConfiguration): void; } /** diff --git a/projects/ngx-matomo-client/core/tracker/internal-matomo-tracker.service.ts b/projects/ngx-matomo-client/core/tracker/internal-matomo-tracker.service.ts index 0dbc653..963549f 100644 --- a/projects/ngx-matomo-client/core/tracker/internal-matomo-tracker.service.ts +++ b/projects/ngx-matomo-client/core/tracker/internal-matomo-tracker.service.ts @@ -18,7 +18,7 @@ function trimTrailingUndefinedElements(array: T[]): T[] { return trimmed; } -type InternalMatomoTrackerType = Pick< +export type InternalMatomoTrackerType = Pick< InternalMatomoTracker, 'get' | 'push' | 'pushFn' >; @@ -30,10 +30,7 @@ export function createInternalMatomoTracker(): InternalMatomoTrackerType { return disabled || !isBrowser ? new NoopMatomoTracker() : new InternalMatomoTracker(); } -@Injectable({ - providedIn: 'root', - useFactory: createInternalMatomoTracker, -}) +@Injectable() export class InternalMatomoTracker { private readonly ngZone = inject(NgZone); private readonly config = inject(INTERNAL_MATOMO_CONFIGURATION); @@ -70,6 +67,7 @@ export class InternalMatomoTracker { } } +@Injectable() export class NoopMatomoTracker implements InternalMatomoTrackerType { diff --git a/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.spec.ts b/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.spec.ts index 3f41960..4b549e5 100644 --- a/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.spec.ts +++ b/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.spec.ts @@ -1,113 +1,151 @@ -import { EnvironmentInjector, PLATFORM_ID, Provider } from '@angular/core'; +import { + ApplicationInitStatus, + InjectionToken, + PLATFORM_ID, + Provider, + Signal, + WritableSignal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; import { MatomoHolder } from '../holder'; -import { provideMatomo, withScriptFactory } from '../providers'; -import { - MATOMO_CONFIGURATION, - MATOMO_ROUTER_ENABLED, - MatomoConfiguration, - MatomoConsentMode, -} from './configuration'; +import { MatomoFeature, provideMatomo, withScriptFactory } from '../providers'; +import { MatomoTestingTracker, provideTestingTracker } from '../testing/testing-tracker'; +import { MATOMO_ROUTER_ENABLED, MatomoConfiguration, MatomoConsentMode } from './configuration'; import { ALREADY_INITIALIZED_ERROR, ALREADY_INJECTED_ERROR } from './errors'; import { MatomoInitializerService } from './matomo-initializer.service'; -import { MatomoTracker } from './matomo-tracker.service'; -import { - createDefaultMatomoScriptElement, - MATOMO_SCRIPT_FACTORY, - MatomoScriptFactory, -} from './script-factory'; +import { createDefaultMatomoScriptElement } from './script-factory'; declare let window: MatomoHolder; describe('MatomoInitializerService', () => { - function instantiate( + const injectedScriptSpyToken = new InjectionToken>( + 'injectedScriptSpyToken', + ); + + async function setUp( config: MatomoConfiguration, providers: Provider[] = [], - ): MatomoInitializerService { + features: MatomoFeature[] = [], + ): Promise<{ + tracker: MatomoTestingTracker; + service: MatomoInitializerService; + injectedScript: Signal; + }> { + const injectedScript = new BehaviorSubject(undefined); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [ + provideMatomo(config, ...features), + provideTestingTracker(), + ...providers, { - provide: MATOMO_CONFIGURATION, - useValue: config, + provide: injectedScriptSpyToken, + useFactory: () => toSignal(injectedScript), }, - ...providers, ], }); - return TestBed.inject(MatomoInitializerService); + setUpScriptInjection(script => injectedScript.next(script)); + + // https://github.com/angular/angular/issues/24218 + await TestBed.inject(ApplicationInitStatus).donePromise; + + return { + service: TestBed.inject(MatomoInitializerService), + tracker: TestBed.inject(MatomoTestingTracker), + injectedScript: TestBed.inject(injectedScriptSpyToken), + }; + } + + function setUpScriptInjection(cb: (injectedScript: HTMLScriptElement) => void): void { + const mockContainer = jasmine.createSpyObj('FakeContainer', ['insertBefore']); + const mockExistingScript = jasmine.createSpyObj('FakeExistingScript', [], { + parentNode: mockContainer, + parentElement: mockContainer, + }); + + mockContainer.insertBefore.and.callFake(script => { + cb(script as unknown as HTMLScriptElement); + return script; + }); + + const getElementsByTagNameSpy = jasmine.isSpy(window.document.getElementsByTagName) + ? (window.document.getElementsByTagName as jasmine.Spy) + : spyOn(window.document, 'getElementsByTagName'); + + // Not a perfect spy, as the actual returned value is an Array, not an HTMLCollection + getElementsByTagNameSpy.and.returnValue([ + mockExistingScript, + ] as unknown as HTMLCollectionOf); + } + + function expectInjectedScript(expectedUrl: string): void { + const script = TestBed.inject(injectedScriptSpyToken)(); + + expect(script).toBeTruthy(); + expect(script?.type).toEqual('text/javascript'); + expect(script?.async).toBeTrue(); + expect(script?.defer).toBeTrue(); + expect(script?.src?.toLowerCase()).toMatch(expectedUrl.toLowerCase()); // script url may be lowercased by browser + } + + function expectNoInjectedScript(): void { + const script = TestBed.inject(injectedScriptSpyToken)(); + + expect(script).toBeUndefined(); } beforeEach(() => delete (window as Partial)._paq); - it('should register _paq global once', () => { - TestBed.configureTestingModule({ - providers: [ - { - provide: MATOMO_CONFIGURATION, - useValue: {}, - }, - ], - }) - .inject(EnvironmentInjector) - .runInContext(() => { - // Given - expect(window._paq).toBeUndefined(); - - // When - new MatomoInitializerService(); - // Then - expect(window._paq).toEqual([]); - const paq = window._paq; - - // When - new MatomoInitializerService(); - // Then - expect(window._paq).toEqual([]); - expect(window._paq).toBe(paq); - }); + it('should register _paq global once', async () => { + expect(window._paq).toBeUndefined(); + + await setUp({ mode: 'manual' }); + + const paq = window._paq; + + expect(window._paq).toEqual([]); + + await setUp({ mode: 'manual' }); + + expect(window._paq).toEqual([]); + expect(window._paq).toBe(paq); }); - it('should track initial page view by default', () => { + it('should track initial page view by default', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', enableLinkTracking: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); + expect(tracker.calls).toEqual([['trackPageView', undefined]]); }); - it('should not track initial page view by default if router is enabled', () => { + it('should not track initial page view by default if router is enabled', async () => { // Given - const service = instantiate( + const { tracker } = await setUp( { mode: 'manual', enableLinkTracking: false, }, [{ provide: MATOMO_ROUTER_ENABLED, useValue: true }], ); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).not.toHaveBeenCalled(); + expect(tracker.calls).not.toEqual( + jasmine.arrayContaining([jasmine.arrayContaining(['trackPageView'])]), + ); }); - it('should manually force track initial page view no matter router is enabled', () => { + it('should manually force track initial page view no matter router is enabled', async () => { // Given - const service = instantiate( + const { tracker } = await setUp( { mode: 'manual', trackAppInitialLoad: true, @@ -115,351 +153,236 @@ describe('MatomoInitializerService', () => { }, [{ provide: MATOMO_ROUTER_ENABLED, useValue: true }], ); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); + expect(tracker.calls).toEqual([['trackPageView', undefined]]); }); - it('should not track initial page view with manual configuration, no matter router enabled', () => { + it('should not track initial page view with manual configuration, no matter router enabled', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', enableLinkTracking: false, trackAppInitialLoad: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).not.toHaveBeenCalled(); + expect(tracker.calls).not.toEqual( + jasmine.arrayContaining([jasmine.arrayContaining(['trackPageView'])]), + ); }); - it('should enable link tracking with manual configuration', () => { + it('should enable link tracking with manual configuration', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', trackAppInitialLoad: false, enableLinkTracking: true, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'enableLinkTracking'); - - // When - service.initialize(); // Then - expect(tracker.enableLinkTracking).toHaveBeenCalledOnceWith(false); + expect(tracker.calls).toEqual([['enableLinkTracking', false]]); }); - it('should enable link tracking using pseudo-clicks with manual configuration', () => { + it('should enable link tracking using pseudo-clicks with manual configuration', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', trackAppInitialLoad: false, enableLinkTracking: 'enable-pseudo', }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'enableLinkTracking'); - - // When - service.initialize(); // Then - expect(tracker.enableLinkTracking).toHaveBeenCalledOnceWith(true); + expect(tracker.calls).toEqual([['enableLinkTracking', true]]); }); - it('should set Do Not Track setting if enabled', () => { + it('should set Do Not Track setting if enabled', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', acceptDoNotTrack: true, trackAppInitialLoad: true, enableLinkTracking: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - spyOn(tracker, 'setDoNotTrack'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); - expect(tracker.setDoNotTrack).toHaveBeenCalledOnceWith(true); - expect(tracker.setDoNotTrack).toHaveBeenCalledBefore(tracker.trackPageView); + // Note: 'setDoNotTrack' should be called BEFORE 'trackPageView' + expect(tracker.calls).toEqual([ + ['setDoNotTrack', true], + ['trackPageView', undefined], + ]); }); - it('should disable campaign parameters if enabled', () => { + it('should disable campaign parameters if enabled', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', disableCampaignParameters: true, trackAppInitialLoad: true, enableLinkTracking: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - spyOn(tracker, 'disableCampaignParameters'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); - expect(tracker.disableCampaignParameters).toHaveBeenCalledTimes(1); - expect(tracker.disableCampaignParameters).toHaveBeenCalledBefore(tracker.trackPageView); + // Note: 'disableCampaignParameters' should be called BEFORE 'trackPageView' + expect(tracker.calls).toEqual([['disableCampaignParameters'], ['trackPageView', undefined]]); }); - it('should require tracking consent if setting is enabled', () => { - for (const value of ['tracking', MatomoConsentMode.TRACKING] as const) { + (['tracking', MatomoConsentMode.TRACKING] as const).forEach(requireConsent => { + it('should require tracking consent if setting is enabled', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', - requireConsent: value, trackAppInitialLoad: true, enableLinkTracking: false, + requireConsent, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - spyOn(tracker, 'requireConsent'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); - expect(tracker.requireConsent).toHaveBeenCalledOnceWith(); - expect(tracker.requireConsent).toHaveBeenCalledBefore(tracker.trackPageView); - } + // Note: 'requireConsent' should be called BEFORE 'trackPageView' + expect(tracker.calls).toEqual([['requireConsent'], ['trackPageView', undefined]]); + }); }); - it('should require cookie consent if setting is enabled', () => { - for (const value of ['cookie', MatomoConsentMode.COOKIE] as const) { + (['cookie', MatomoConsentMode.COOKIE] as const).forEach(requireConsent => { + it('should require cookie consent if setting is enabled', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', - requireConsent: value, trackAppInitialLoad: true, enableLinkTracking: false, + requireConsent, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - spyOn(tracker, 'requireCookieConsent'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); - expect(tracker.requireCookieConsent).toHaveBeenCalledOnceWith(); - expect(tracker.requireCookieConsent).toHaveBeenCalledBefore(tracker.trackPageView); - } + // Note: 'requireCookieConsent' should be called BEFORE 'trackPageView' + expect(tracker.calls).toEqual([['requireCookieConsent'], ['trackPageView', undefined]]); + }); }); - it('should not require any consent if setting is not enabled', () => { - for (const value of ['none', MatomoConsentMode.NONE, undefined] as const) { + (['none', MatomoConsentMode.NONE, undefined] as const).forEach(requireConsent => { + it(`should not require any consent if setting is not enabled (${requireConsent})`, async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', - requireConsent: value, trackAppInitialLoad: true, enableLinkTracking: false, + requireConsent, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - spyOn(tracker, 'requireCookieConsent'); - spyOn(tracker, 'requireConsent'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); - expect(tracker.requireCookieConsent).not.toHaveBeenCalled(); - expect(tracker.requireConsent).not.toHaveBeenCalled(); - } + // Note: 'requireConsent' should be called BEFORE 'trackPageView' + expect(tracker.calls).toEqual([['trackPageView', undefined]]); + expect(tracker.calls).not.toEqual( + jasmine.objectContaining([jasmine.arrayContaining(['requireConsent'])]), + ); + expect(tracker.calls).not.toEqual( + jasmine.objectContaining([jasmine.arrayContaining(['requireCookieConsent'])]), + ); + }); }); - it('should enable JS errors tracking if enabled', () => { + it('should enable JS errors tracking if enabled', async () => { // Given - const service = instantiate({ + const { tracker } = await setUp({ mode: 'manual', trackAppInitialLoad: true, enableJSErrorTracking: true, + enableLinkTracking: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'trackPageView'); - spyOn(tracker, 'enableJSErrorTracking'); - - // When - service.initialize(); // Then - expect(tracker.trackPageView).toHaveBeenCalledTimes(1); - expect(tracker.enableJSErrorTracking).toHaveBeenCalledTimes(1); - expect(tracker.enableJSErrorTracking).toHaveBeenCalledBefore(tracker.trackPageView); + // Note: 'enableJSErrorTracking' should be called BEFORE 'trackPageView' + expect(tracker.calls).toEqual([['enableJSErrorTracking'], ['trackPageView', undefined]]); }); - function setUpScriptInjection(cb: (injectedScript: HTMLScriptElement) => void): void { - const mockContainer = jasmine.createSpyObj('FakeContainer', ['insertBefore']); - const mockExistingScript = jasmine.createSpyObj('FakeExistingScript', [], { - parentNode: mockContainer, - parentElement: mockContainer, - }); - - mockContainer.insertBefore.and.callFake(script => { - cb(script as unknown as HTMLScriptElement); - return script; - }); - - spyOn(window.document, 'getElementsByTagName').and.returnValue([ - mockExistingScript, - ] as unknown as HTMLCollectionOf); - } - - function expectInjectedScript(script: HTMLScriptElement | undefined, expectedUrl: string): void { - expect(script).toBeTruthy(); - expect(script?.type).toEqual('text/javascript'); - expect(script?.async).toBeTrue(); - expect(script?.defer).toBeTrue(); - expect(script?.src.toLowerCase()).toMatch(expectedUrl.toLowerCase()); // script url may be lowercased by browser - } - - it('should inject script automatically with simple configuration', () => { + it('should inject script automatically with simple configuration', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + const { tracker } = await setUp({ siteId: 'fakeSiteId', trackerUrl: 'http://fakeTrackerUrl', + enableLinkTracking: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'setTrackerUrl'); - spyOn(tracker, 'setSiteId'); - setUpScriptInjection(script => (injectedScript = script)); - - // When - service.initialize(); // Then - expectInjectedScript(injectedScript, 'http://fakeTrackerUrl/matomo.js'); - expect(tracker.setTrackerUrl).toHaveBeenCalledOnceWith('http://fakeTrackerUrl/matomo.php'); - expect(tracker.setSiteId).toHaveBeenCalledOnceWith('fakeSiteId'); + expectInjectedScript('http://fakeTrackerUrl/matomo.js'); + expect(tracker.calls).toEqual([ + ['trackPageView', undefined], + ['setTrackerUrl', 'http://fakeTrackerUrl/matomo.php'], + ['setSiteId', 'fakeSiteId'], + ]); }); - it('should inject script automatically with site id as number', () => { + it('should inject script automatically with site id as number', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + const { tracker } = await setUp({ siteId: 99, trackerUrl: 'http://fakeTrackerUrl', + enableLinkTracking: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'setTrackerUrl'); - spyOn(tracker, 'setSiteId'); - setUpScriptInjection(script => (injectedScript = script)); - - // When - service.initialize(); // Then - expectInjectedScript(injectedScript, 'http://fakeTrackerUrl/matomo.js'); - expect(tracker.setTrackerUrl).toHaveBeenCalledOnceWith('http://fakeTrackerUrl/matomo.php'); - expect(tracker.setSiteId).toHaveBeenCalledOnceWith('99'); + expectInjectedScript('http://fakeTrackerUrl/matomo.js'); + expect(tracker.calls).toEqual([ + ['trackPageView', undefined], + ['setTrackerUrl', 'http://fakeTrackerUrl/matomo.php'], + ['setSiteId', '99'], + ]); }); - it('should inject script automatically with custom script url', () => { + it('should inject script automatically with custom script url', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + await setUp({ siteId: 'fakeSiteId', trackerUrl: 'http://fakeTrackerUrl', scriptUrl: 'http://myCustomScriptUrl', }); - setUpScriptInjection(script => (injectedScript = script)); - - // When - service.initialize(); - // Then - expectInjectedScript(injectedScript, 'http://myCustomScriptUrl'); + expectInjectedScript('http://myCustomScriptUrl'); }); - it('should inject script with embedded tracker configuration', () => { + it('should inject script with embedded tracker configuration', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + const { tracker } = await setUp({ scriptUrl: 'http://myCustomScript.js', }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'setTrackerUrl'); - spyOn(tracker, 'setSiteId'); - - setUpScriptInjection(script => (injectedScript = script)); - - // When - service.initialize(); // Then - expectInjectedScript(injectedScript, 'http://myCustomScript.js'); - expect(tracker.setTrackerUrl).not.toHaveBeenCalled(); - expect(tracker.setSiteId).not.toHaveBeenCalled(); + expectInjectedScript('http://myCustomScript.js'); + expect(tracker.calls).not.toEqual( + jasmine.arrayContaining([jasmine.arrayContaining(['setTrackerUrl'])]), + ); + expect(tracker.calls).not.toEqual( + jasmine.arrayContaining([jasmine.arrayContaining(['setSiteId'])]), + ); }); - it('should inject script automatically with multiple trackers', () => { + it('should inject script automatically with multiple trackers', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + const { tracker } = await setUp({ + enableLinkTracking: false, trackers: [ { siteId: 'site1', trackerUrl: 'http://fakeTrackerUrl1' }, { siteId: 'site2', trackerUrl: 'http://fakeTrackerUrl2/' }, // Should work with trailing slash { siteId: 'site3', trackerUrl: 'http://fakeTrackerUrl3' }, ], }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'setTrackerUrl'); - spyOn(tracker, 'setSiteId'); - spyOn(tracker, 'addTracker'); - setUpScriptInjection(script => (injectedScript = script)); - - // When - service.initialize(); // Then - expectInjectedScript(injectedScript, 'http://fakeTrackerUrl1/matomo.js'); - expect(tracker.setTrackerUrl).toHaveBeenCalledOnceWith('http://fakeTrackerUrl1/matomo.php'); - expect(tracker.setSiteId).toHaveBeenCalledOnceWith('site1'); - expect(tracker.addTracker).toHaveBeenCalledWith('http://fakeTrackerUrl2/matomo.php', 'site2'); - expect(tracker.addTracker).toHaveBeenCalledWith('http://fakeTrackerUrl3/matomo.php', 'site3'); - expect(tracker.addTracker).toHaveBeenCalledTimes(2); + expectInjectedScript('http://fakeTrackerUrl1/matomo.js'); + expect(tracker.calls).toEqual([ + ['trackPageView', undefined], + ['setTrackerUrl', 'http://fakeTrackerUrl1/matomo.php'], + ['setSiteId', 'site1'], + ['addTracker', 'http://fakeTrackerUrl2/matomo.php', 'site2'], + ['addTracker', 'http://fakeTrackerUrl3/matomo.php', 'site3'], + ]); }); - it('should append custom tracker suffix if configured, matomo.php otherwise', () => { + it('should append custom tracker suffix if configured, matomo.php otherwise', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + const { tracker } = await setUp({ + enableLinkTracking: false, trackers: [ { siteId: 'site1', trackerUrl: 'http://fakeTrackerUrl1', trackerUrlSuffix: '' }, { @@ -470,54 +393,41 @@ describe('MatomoInitializerService', () => { { siteId: 'site3', trackerUrl: 'http://fakeTrackerUrl3' }, ], }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'setTrackerUrl'); - spyOn(tracker, 'setSiteId'); - spyOn(tracker, 'addTracker'); - setUpScriptInjection(script => (injectedScript = script)); - - // When - service.initialize(); // Then - expectInjectedScript(injectedScript, 'http://fakeTrackerUrl1/matomo.js'); - expect(tracker.setTrackerUrl).toHaveBeenCalledOnceWith('http://fakeTrackerUrl1'); - expect(tracker.setSiteId).toHaveBeenCalledOnceWith('site1'); - expect(tracker.addTracker).toHaveBeenCalledWith( - 'http://fakeTrackerUrl2/custom-tracker.php', - 'site2', - ); - expect(tracker.addTracker).toHaveBeenCalledWith('http://fakeTrackerUrl3/matomo.php', 'site3'); - expect(tracker.addTracker).toHaveBeenCalledTimes(2); + expectInjectedScript('http://fakeTrackerUrl1/matomo.js'); + + expect(tracker.calls).toEqual([ + ['trackPageView', undefined], + ['setTrackerUrl', 'http://fakeTrackerUrl1'], + ['setSiteId', 'site1'], + ['addTracker', 'http://fakeTrackerUrl2/custom-tracker.php', 'site2'], + ['addTracker', 'http://fakeTrackerUrl3/matomo.php', 'site3'], + ]); }); - it('should do nothing when disabled', () => { + it('should do nothing when disabled', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + const { service } = await setUp({ disabled: true, siteId: 'fakeSiteId', trackerUrl: 'http://fakeTrackerUrl', }); - setUpScriptInjection(script => (injectedScript = script)); - // When - service.initialize(); service.initializeTracker({ trackerUrl: '', siteId: '' }); // Then - expect(injectedScript).toBeUndefined(); + expectNoInjectedScript(); expect(window._paq).toBeUndefined(); }); - it('should do nothing when platform is not browser', () => { + it('should do nothing when platform is not browser', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; // See here: https://github.com/angular/angular/blob/b66e479cdb1e474a29ff676f10a5fcc3d7eae799/packages/common/src/platform_id.ts const serverPlatform = 'server'; - const service = instantiate( + + const { service } = await setUp( { disabled: false, siteId: 'fakeSiteId', @@ -526,110 +436,75 @@ describe('MatomoInitializerService', () => { [{ provide: PLATFORM_ID, useValue: serverPlatform }], ); - setUpScriptInjection(script => (injectedScript = script)); - // When - service.initialize(); + service.initializeTracker({ trackerUrl: '', siteId: '' }); // Then - expect(injectedScript).toBeUndefined(); + expectNoInjectedScript(); expect(window._paq).toBeUndefined(); }); - it('should create custom script tag', () => { + it('should create custom script tag', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - - TestBed.configureTestingModule({ - providers: [ - { - provide: MATOMO_CONFIGURATION, - useValue: { - siteId: 1, - trackerUrl: '', - scriptUrl: '/fake/script/url', - } as MatomoConfiguration, - }, - { - provide: MATOMO_SCRIPT_FACTORY, - useValue: ((scriptUrl, document) => { - const script = createDefaultMatomoScriptElement(scriptUrl, document); + const { injectedScript } = await setUp( + { + siteId: 1, + trackerUrl: '', + scriptUrl: '/fake/script/url', + }, + [], + [ + withScriptFactory((scriptUrl, document) => { + const script = createDefaultMatomoScriptElement(scriptUrl, document); - script.setAttribute('data-cookieconsent', 'statistics'); + script.setAttribute('data-cookieconsent', 'statistics'); - return script; - }) as MatomoScriptFactory, - }, + return script; + }), ], - }); - - const service = TestBed.inject(MatomoInitializerService); - - setUpScriptInjection(script => (injectedScript = script)); - - // When - service.initialize(); + ); // Then - expect(injectedScript?.src).toMatch('^(.+://[^/]+)?/fake/script/url$'); - expect(injectedScript?.dataset.cookieconsent).toEqual('statistics'); + expect(injectedScript()?.src).toMatch('^(.+://[^/]+)?/fake/script/url$'); + expect(injectedScript()?.dataset.cookieconsent).toEqual('statistics'); }); - it('should create custom script tag with forRoot factory', () => { + it('should create custom script tag with forRoot factory', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; + const { injectedScript } = await setUp( + { + siteId: 1, + trackerUrl: '', + scriptUrl: '/fake/script/url', + }, + [], + [ + withScriptFactory((scriptUrl, document) => { + const script = createDefaultMatomoScriptElement(scriptUrl, document); - setUpScriptInjection(script => (injectedScript = script)); + script.setAttribute('data-cookieconsent', 'statistics'); - TestBed.configureTestingModule({ - providers: [ - provideMatomo( - { - siteId: 1, - trackerUrl: '', - scriptUrl: '/fake/script/url', - } as MatomoConfiguration, - withScriptFactory((scriptUrl, document) => { - const script = createDefaultMatomoScriptElement(scriptUrl, document); - - script.setAttribute('data-cookieconsent', 'statistics'); - - return script; - }), - ), + return script; + }), ], - }); - - // Inject service to trigger initialization on module init - TestBed.inject(MatomoInitializerService); + ); // Then - expect(injectedScript?.src).toMatch('^(.+://[^/]+)?/fake/script/url$'); - expect(injectedScript?.dataset.cookieconsent).toEqual('statistics'); + expect(injectedScript()?.src).toMatch('^(.+://[^/]+)?/fake/script/url$'); + expect(injectedScript()?.dataset.cookieconsent).toEqual('statistics'); }); - it('should defer script injection until tracker configuration is provided', () => { + it('should defer script injection until tracker configuration is provided', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const service = instantiate({ + const { tracker, service } = await setUp({ mode: 'deferred', trackAppInitialLoad: true, + enableLinkTracking: false, }); - const tracker = TestBed.inject(MatomoTracker); - - spyOn(tracker, 'setTrackerUrl'); - spyOn(tracker, 'setSiteId'); - spyOn(tracker, 'trackPageView'); - setUpScriptInjection(script => (injectedScript = script)); - // When - service.initialize(); // Then - expect(injectedScript).toBeFalsy(); - expect(tracker.setTrackerUrl).not.toHaveBeenCalled(); - expect(tracker.setSiteId).not.toHaveBeenCalled(); - // Pre-init actions must run - expect(tracker.trackPageView).toHaveBeenCalledOnceWith(); + expectNoInjectedScript(); + expect(tracker.calls).toEqual([['trackPageView', undefined]]); // When service.initializeTracker({ @@ -638,14 +513,17 @@ describe('MatomoInitializerService', () => { }); // Then - expectInjectedScript(injectedScript, 'http://fakeTrackerUrl/matomo.js'); - expect(tracker.setTrackerUrl).toHaveBeenCalledOnceWith('http://fakeTrackerUrl/matomo.php'); - expect(tracker.setSiteId).toHaveBeenCalledOnceWith('fakeSiteId'); + expectInjectedScript('http://fakeTrackerUrl/matomo.js'); + expect(tracker.calls).toEqual([ + ['trackPageView', undefined], + ['setTrackerUrl', 'http://fakeTrackerUrl/matomo.php'], + ['setSiteId', 'fakeSiteId'], + ]); }); - it('should map deprecated init() method to initialize()', () => { + it('should map deprecated init() method to initialize()', async () => { // Given - const service = instantiate({ + const { service } = await setUp({ trackerUrl: '', siteId: '', }); @@ -659,9 +537,9 @@ describe('MatomoInitializerService', () => { expect(service.initialize).toHaveBeenCalledOnceWith(); }); - it('should throw an error when initialized trackers more than once', () => { + it('should throw an error when initialized trackers more than once', async () => { // Given - const service = instantiate({ + const { service } = await setUp({ mode: 'deferred', }); @@ -674,16 +552,13 @@ describe('MatomoInitializerService', () => { ); }); - it('should throw an error when initialized more than once', () => { + it('should throw an error when initialized more than once', async () => { // Given - const service = instantiate({ + const { service } = await setUp({ trackerUrl: '', siteId: '', }); - // When - service.initialize(); - // Then expect(() => service.initialize()).toThrowError(ALREADY_INITIALIZED_ERROR); }); diff --git a/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.ts b/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.ts index 538e914..6f0fce2 100644 --- a/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.ts +++ b/projects/ngx-matomo-client/core/tracker/matomo-initializer.service.ts @@ -10,6 +10,7 @@ import { DEFERRED_INTERNAL_MATOMO_CONFIGURATION, getTrackersConfiguration, INTERNAL_MATOMO_CONFIGURATION, + InternalMatomoConfiguration, isAutoConfigurationMode, isEmbeddedTrackerConfiguration, isExplicitTrackerConfiguration, @@ -53,10 +54,7 @@ export class NoopMatomoInitializer implements PublicInterface { TestBed.configureTestingModule({ providers: [ + MatomoTracker, { provide: InternalMatomoTracker, useValue: delegate, diff --git a/projects/ngx-matomo-client/core/tracker/matomo-tracker.service.ts b/projects/ngx-matomo-client/core/tracker/matomo-tracker.service.ts index 9915b66..8e2dfc9 100644 --- a/projects/ngx-matomo-client/core/tracker/matomo-tracker.service.ts +++ b/projects/ngx-matomo-client/core/tracker/matomo-tracker.service.ts @@ -98,9 +98,7 @@ export interface MatomoInstance { getExcludedReferrers(): string[]; } -@Injectable({ - providedIn: 'root', -}) +@Injectable() export class MatomoTracker { private readonly delegate: InternalMatomoTracker = inject(InternalMatomoTracker); diff --git a/projects/ngx-matomo-client/core/utils/script-injector.ts b/projects/ngx-matomo-client/core/utils/script-injector.ts index 497ae86..e7b4667 100644 --- a/projects/ngx-matomo-client/core/utils/script-injector.ts +++ b/projects/ngx-matomo-client/core/utils/script-injector.ts @@ -3,7 +3,7 @@ import { inject, Injectable, INJECTOR, runInInjectionContext } from '@angular/co import { MATOMO_SCRIPT_FACTORY } from '../tracker/script-factory'; import { requireNonNull } from './coercion'; -@Injectable({ providedIn: 'root' }) +@Injectable() export class ScriptInjector { private readonly scriptFactory = inject(MATOMO_SCRIPT_FACTORY); private readonly injector = inject(INJECTOR); diff --git a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.spec.ts b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.spec.ts index 7acdf61..b380415 100644 --- a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.spec.ts +++ b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.spec.ts @@ -1,60 +1,68 @@ import { ɵPLATFORM_SERVER_ID } from '@angular/common'; -import { PLATFORM_ID, Provider } from '@angular/core'; +import { + ApplicationInitStatus, + ErrorHandler, + InjectionToken, + PLATFORM_ID, + Provider, + Signal, + WritableSignal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { - AutoMatomoConfiguration, - InternalMatomoConfiguration, - MATOMO_CONFIGURATION, + MatomoConfiguration, + MatomoFeature, MatomoTracker, - ɵASYNC_INTERNAL_MATOMO_CONFIGURATION as ASYNC_INTERNAL_MATOMO_CONFIGURATION, - ɵDEFERRED_INTERNAL_MATOMO_CONFIGURATION as DEFERRED_INTERNAL_MATOMO_CONFIGURATION, + provideMatomo, + ɵMatomoTestingTracker as MatomoTestingTracker, + ɵprovideTestingTracker as provideTestingTracker, } from 'ngx-matomo-client/core'; -import { EMPTY, Observable, Subject } from 'rxjs'; -import { - MATOMO_FORM_ANALYTICS_CONFIGURATION, - MatomoFormAnalyticsConfiguration, -} from './configuration'; -import { MatomoFormAnalyticsInitializer } from './matomo-form-analytics-initializer.service'; -import { MatomoFormAnalytics } from './matomo-form-analytics.service'; +import { BehaviorSubject } from 'rxjs'; +import { MatomoFormAnalyticsConfiguration } from './configuration'; +import { withFormAnalytics } from './providers'; describe('MatomoFormAnalyticsInitializer', () => { - async function instantiate( + const injectedScriptSpyToken = new InjectionToken>( + 'injectedScriptSpyToken', + ); + + async function setUp( formAnalyticsConfig: MatomoFormAnalyticsConfiguration, - config: Partial, + config: MatomoConfiguration, providers: Provider[] = [], - pageViewTracked: Observable = EMPTY, - ): Promise { + features: MatomoFeature[] = [], + ): Promise<{ + tracker: MatomoTestingTracker; + service: MatomoTracker; + injectedScript: Signal; + }> { + const injectedScript = new BehaviorSubject(undefined); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [ + provideMatomo(config, withFormAnalytics(formAnalyticsConfig), ...features), + provideTestingTracker(), + ...providers, { - provide: MATOMO_CONFIGURATION, - useValue: config, - }, - { - provide: MATOMO_FORM_ANALYTICS_CONFIGURATION, - useValue: formAnalyticsConfig, - }, - { - provide: MatomoTracker, - useValue: jasmine.createSpyObj('MatomoTracker', [], { - pageViewTracked, - }), - }, - { - provide: MatomoFormAnalytics, - useValue: jasmine.createSpyObj('MatomoFormAnalytics', [ - 'scanForForms', - 'disableFormAnalytics', - ]), + provide: injectedScriptSpyToken, + useFactory: () => toSignal(injectedScript), }, - ...providers, ], }); - TestBed.inject(DEFERRED_INTERNAL_MATOMO_CONFIGURATION).markReady({} as AutoMatomoConfiguration); - await TestBed.inject(ASYNC_INTERNAL_MATOMO_CONFIGURATION); + setUpScriptInjection(script => injectedScript.next(script)); + + // https://github.com/angular/angular/issues/24218 + await TestBed.inject(ApplicationInitStatus).donePromise; - return TestBed.inject(MatomoFormAnalyticsInitializer); + return { + service: TestBed.inject(MatomoTracker), + tracker: TestBed.inject(MatomoTestingTracker), + injectedScript: TestBed.inject(injectedScriptSpyToken), + }; } function setUpScriptInjection(cb: (injectedScript: HTMLScriptElement) => void): void { @@ -69,23 +77,35 @@ describe('MatomoFormAnalyticsInitializer', () => { return script; }); - spyOn(window.document, 'getElementsByTagName').and.returnValue([ + const getElementsByTagNameSpy = jasmine.isSpy(window.document.getElementsByTagName) + ? (window.document.getElementsByTagName as jasmine.Spy) + : spyOn(window.document, 'getElementsByTagName'); + + // Not a perfect spy, as the actual returned value is an Array, not an HTMLCollection + getElementsByTagNameSpy.and.returnValue([ mockExistingScript, ] as unknown as HTMLCollectionOf); } - function expectInjectedScript(script: HTMLScriptElement | undefined, expectedUrl: string): void { + function expectInjectedScript(expectedUrl: string): void { + const script = TestBed.inject(injectedScriptSpyToken)(); + expect(script).toBeTruthy(); expect(script?.type).toEqual('text/javascript'); expect(script?.async).toBeTrue(); expect(script?.defer).toBeTrue(); - expect(script?.src.toLowerCase()).toMatch(expectedUrl.toLowerCase()); // script url may be lowercased by browser + expect(script?.src?.toLowerCase()).toMatch(expectedUrl.toLowerCase()); // script url may be lowercased by browser + } + + function expectNoInjectedScript(): void { + const script = TestBed.inject(injectedScriptSpyToken)(); + + expect(script).toBeUndefined(); } it('should inject default script', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const initializer = await instantiate( + await setUp( { loadScript: true, }, @@ -95,22 +115,13 @@ describe('MatomoFormAnalyticsInitializer', () => { }, ); - setUpScriptInjection(script => (injectedScript = script)); - - // When - await initializer.initialize(); - // Then - expectInjectedScript( - injectedScript, - 'http://test.localhost/plugins/FormAnalytics/tracker.min.js', - ); + expectInjectedScript('http://test.localhost/plugins/FormAnalytics/tracker.min.js'); }); it('should inject script from custom url', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const initializer = await instantiate( + await setUp( { loadScript: 'http://custom.test.url/script.js', }, @@ -120,121 +131,124 @@ describe('MatomoFormAnalyticsInitializer', () => { }, ); - setUpScriptInjection(script => (injectedScript = script)); - - // When - await initializer.initialize(); - // Then - expectInjectedScript(injectedScript, 'http://custom.test.url/script.js'); + expectInjectedScript('http://custom.test.url/script.js'); }); it('should throw when trying to inject default script without tracker configuration', async () => { // Given - let injectedScript: HTMLScriptElement | undefined; - const initializer = await instantiate( + let resolveCaughtError: (error: unknown) => void; + const caughtError = new Promise(resolve => (resolveCaughtError = resolve)); + + await setUp( { loadScript: true, }, { mode: 'manual', }, + [ + { + provide: ErrorHandler, + useValue: { + handleError: error => resolveCaughtError(error), + } satisfies ErrorHandler, + }, + ], ); - setUpScriptInjection(script => (injectedScript = script)); - - // When - await expectAsync(initializer.initialize()).toBeRejected(); - // Then - expect(injectedScript).toBeUndefined(); + // TODO change to expect error when #102 is fixed + await expectAsync(caughtError).toBePending(); + // await expectAsync(caughtError).toBeResolvedTo( + // new Error( + // 'Cannot resolve default matomo FormAnalytics plugin script url. ' + + // 'Please explicitly provide `loadScript` configuration property instead of `true`', + // ), + // ); + expectNoInjectedScript(); }); it('should rescan for forms after each page track', async () => { // Given - const pageViewTracked = new Subject(); - const initializer = await instantiate({}, {}, [], pageViewTracked); - const formAnalytics = TestBed.inject(MatomoFormAnalytics); + const { tracker, service } = await setUp({}, { mode: 'manual' }); - await initializer.initialize(); - expect(formAnalytics.scanForForms).not.toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([]); - // When - pageViewTracked.next(); - // Then - expect(formAnalytics.scanForForms).toHaveBeenCalledTimes(1); + service.trackPageView(); - // When - pageViewTracked.next(); - // Then - expect(formAnalytics.scanForForms).toHaveBeenCalledTimes(2); + expect(tracker.callsAfterInit).toEqual([ + ['trackPageView', undefined], + ['FormAnalytics::scanForForms', undefined], + ]); + + service.trackPageView(); + + expect(tracker.callsAfterInit).toEqual([ + ['trackPageView', undefined], + ['FormAnalytics::scanForForms', undefined], + ['trackPageView', undefined], + ['FormAnalytics::scanForForms', undefined], + ]); }); it('should rescan for forms after each page track with delay', fakeAsync(() => { // Given - const pageViewTracked = new Subject(); - let initializer!: MatomoFormAnalyticsInitializer; - instantiate( + setUp( { autoScanDelay: 42, }, - {}, - [], - pageViewTracked, - ).then(res => (initializer = res)); - - tick(); - expect(initializer).toBeDefined(); + { mode: 'manual' }, + ); - const formAnalytics = TestBed.inject(MatomoFormAnalytics); + const tracker = TestBed.inject(MatomoTestingTracker); + const client = TestBed.inject(MatomoTracker); - initializer.initialize(); + tick(); + expect(tracker!).toBeDefined(); // When - pageViewTracked.next(); + client.trackPageView(); tick(); // Then - expect(formAnalytics.scanForForms).not.toHaveBeenCalled(); + expect(tracker!.callsAfterInit).toEqual([['trackPageView', undefined]]); // When tick(42); // Then - expect(formAnalytics.scanForForms).toHaveBeenCalledTimes(1); + expect(tracker!.callsAfterInit).toEqual([ + ['trackPageView', undefined], + ['FormAnalytics::scanForForms', undefined], + ]); })); it('should disable form analytics', async () => { // Given - const initializer = await instantiate( + const { tracker } = await setUp( { disabled: true, }, - {}, + { mode: 'manual', enableLinkTracking: false }, [], ); - const formAnalytics = TestBed.inject(MatomoFormAnalytics); - await initializer.initialize(); - expect(formAnalytics.disableFormAnalytics).toHaveBeenCalledTimes(1); + expect(tracker.calls).toEqual([ + ['trackPageView', undefined], + ['FormAnalytics::disableFormAnalytics'], + ]); }); it('should implicitly disable form analytics when not running in browser', async () => { // Given - const pageViewTracked = new Subject(); - const initializer = await instantiate( - {}, - {}, - [{ provide: PLATFORM_ID, useValue: ɵPLATFORM_SERVER_ID }], - pageViewTracked, - ); - const formAnalytics = TestBed.inject(MatomoFormAnalytics); + const { tracker, service } = await setUp({}, { mode: 'manual' }, [ + { provide: PLATFORM_ID, useValue: ɵPLATFORM_SERVER_ID }, + ]); - await initializer.initialize(); - expect(formAnalytics.scanForForms).not.toHaveBeenCalled(); + expect(tracker.calls).toEqual([]); // When - pageViewTracked.next(); + service.trackPageView(); // Then - expect(formAnalytics.scanForForms).not.toHaveBeenCalled(); - expect(formAnalytics.disableFormAnalytics).not.toHaveBeenCalled(); + expect(tracker.calls).toEqual([['trackPageView', undefined]]); }); }); diff --git a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.ts b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.ts index bbaad0e..0e3d2ff 100644 --- a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.ts +++ b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics-initializer.service.ts @@ -16,9 +16,7 @@ import { MatomoFormAnalytics } from './matomo-form-analytics.service'; const DEFAULT_SCRIPT_SUFFIX = 'plugins/FormAnalytics/tracker.min.js'; -@Injectable({ - providedIn: 'root', -}) +@Injectable() export class MatomoFormAnalyticsInitializer implements OnDestroy { private readonly config = inject(INTERNAL_MATOMO_FORM_ANALYTICS_CONFIGURATION); private readonly coreConfig = inject(ASYNC_INTERNAL_MATOMO_CONFIGURATION); diff --git a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.spec.ts b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.spec.ts index 24ee44f..82776d3 100644 --- a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.spec.ts +++ b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.spec.ts @@ -1,31 +1,28 @@ import { TestBed } from '@angular/core/testing'; import { + MatomoConfiguration, + provideMatomo, ɵGetters as Getters, - ɵInternalMatomoTracker as InternalMatomoTracker, ɵMethods as Methods, + ɵprovideTestingTracker as provideTestingTracker, + ɵMatomoTestingTracker as MatomoTestingTracker, } from 'ngx-matomo-client/core'; -import { MatomoFormAnalytics, MatomoFormAnalyticsInstance } from './matomo-form-analytics.service'; +import { MatomoFormAnalytics } from './matomo-form-analytics.service'; +import { withFormAnalytics } from './providers'; describe('MatomoFormAnalytics', () => { - let delegate: jasmine.SpyObj< - InternalMatomoTracker - >; let formAnalytics: MatomoFormAnalytics; + let tracker: MatomoTestingTracker; beforeEach(() => { - delegate = jasmine.createSpyObj< - InternalMatomoTracker - >(['get', 'push', 'pushFn']); - TestBed.configureTestingModule({ providers: [ - { - provide: InternalMatomoTracker, - useValue: delegate, - }, + provideMatomo({} as MatomoConfiguration, withFormAnalytics()), + provideTestingTracker(), ], }); + tracker = TestBed.inject(MatomoTestingTracker); formAnalytics = TestBed.inject(MatomoFormAnalytics); }); @@ -37,16 +34,15 @@ describe('MatomoFormAnalytics', () => { // When when(formAnalytics); // Then - const allArgs = delegate.push.calls.allArgs(); + const allArgs = tracker.callsAfterInit; expect(allArgs.length).toEqual(expected.length); for (let callIndex = 0; callIndex < allArgs.length; callIndex++) { const callArgs = allArgs[callIndex]; - expect(callArgs).toHaveSize(1); - for (let argIndex = 0; argIndex < callArgs[0].length; argIndex++) { - expect(callArgs[0][argIndex]).toEqual(expected[callIndex][argIndex]); + for (let argIndex = 0; argIndex < callArgs.length; argIndex++) { + expect(callArgs[argIndex]).toEqual(expected[callIndex][argIndex]); } } }; @@ -68,7 +64,7 @@ describe('MatomoFormAnalytics', () => { expected: E, ): Promise { // Given - delegate.get.and.returnValue(Promise.resolve(expected) as Promise); + spyOn(tracker, 'get').and.returnValue(Promise.resolve(expected) as Promise); // When return (formAnalytics[getter]() as Promise).then(url => { diff --git a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.ts b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.ts index a2ce063..78581cd 100644 --- a/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.ts +++ b/projects/ngx-matomo-client/form-analytics/matomo-form-analytics.service.ts @@ -10,9 +10,7 @@ export interface MatomoFormAnalyticsInstance { setTrackingTimer(delayInMilliSeconds: number): void; } -@Injectable({ - providedIn: 'root', -}) +@Injectable() export class MatomoFormAnalytics { private readonly delegate: InternalMatomoTracker = inject(InternalMatomoTracker); diff --git a/projects/ngx-matomo-client/form-analytics/providers.ts b/projects/ngx-matomo-client/form-analytics/providers.ts index 820792a..fb65af2 100644 --- a/projects/ngx-matomo-client/form-analytics/providers.ts +++ b/projects/ngx-matomo-client/form-analytics/providers.ts @@ -1,4 +1,4 @@ -import { ENVIRONMENT_INITIALIZER, inject } from '@angular/core'; +import { ENVIRONMENT_INITIALIZER, ErrorHandler, inject } from '@angular/core'; import { MatomoFeature as MatomoFeature, ɵcreateMatomoFeature as createMatomoFeature, @@ -8,6 +8,7 @@ import { MatomoFormAnalyticsConfiguration, } from './configuration'; import { MatomoFormAnalyticsInitializer } from './matomo-form-analytics-initializer.service'; +import { MatomoFormAnalytics } from './matomo-form-analytics.service'; /** * Additional Matomo router features kind @@ -19,6 +20,8 @@ export const enum FormAnalyticsMatomoFeatureKind { /** Enable automatic page views tracking */ export function withFormAnalytics(config?: MatomoFormAnalyticsConfiguration): MatomoFeature { const providers = [ + MatomoFormAnalytics, + MatomoFormAnalyticsInitializer, { provide: MATOMO_FORM_ANALYTICS_CONFIGURATION, useValue: config, @@ -27,7 +30,13 @@ export function withFormAnalytics(config?: MatomoFormAnalyticsConfiguration): Ma provide: ENVIRONMENT_INITIALIZER, multi: true, useValue() { - inject(MatomoFormAnalyticsInitializer).initialize(); + const errorHandler = inject(ErrorHandler); + + // Do NOT wait here for initialization, because app startup should NOT be blocked until deferred config is resolved + // However, correctly propagate errors to error handler + Promise.resolve(inject(MatomoFormAnalyticsInitializer).initialize()).catch(error => + errorHandler.handleError(error), + ); }, }, ]; diff --git a/projects/ngx-matomo-client/router/matomo-router.module.ts b/projects/ngx-matomo-client/router/matomo-router.module.ts index 2750d4c..3f5c308 100644 --- a/projects/ngx-matomo-client/router/matomo-router.module.ts +++ b/projects/ngx-matomo-client/router/matomo-router.module.ts @@ -41,6 +41,7 @@ export class MatomoRouterModule { return { ngModule: MatomoRouterModule, providers: [ + MatomoRouter, { provide: MATOMO_ROUTER_CONFIGURATION, useValue: configWithInterceptors }, provideInterceptors(configWithInterceptors.interceptors), ], diff --git a/projects/ngx-matomo-client/router/matomo-router.service.spec.ts b/projects/ngx-matomo-client/router/matomo-router.service.spec.ts index 33cef71..3ae039f 100644 --- a/projects/ngx-matomo-client/router/matomo-router.service.spec.ts +++ b/projects/ngx-matomo-client/router/matomo-router.service.spec.ts @@ -2,19 +2,20 @@ import { ɵPLATFORM_BROWSER_ID, ɵPLATFORM_SERVER_ID } from '@angular/common'; import { PLATFORM_ID, Provider } from '@angular/core'; import { fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { Event, NavigationEnd, Router } from '@angular/router'; -import { MATOMO_CONFIGURATION, MatomoTracker } from 'ngx-matomo-client/core'; -import { of, Subject } from 'rxjs'; import { - InternalGlobalConfiguration, - MATOMO_ROUTER_CONFIGURATION, - MatomoRouterConfiguration, - NavigationEndComparator, -} from './configuration'; + MatomoConfiguration, + provideMatomo, + ɵMatomoTestingTracker as MatomoTestingTracker, + ɵprovideTestingTracker as provideTestingTracker, +} from 'ngx-matomo-client/core'; +import { of, Subject } from 'rxjs'; +import { MatomoRouterConfiguration, NavigationEndComparator } from './configuration'; import { invalidInterceptorsProviderError } from './errors'; import { MATOMO_ROUTER_INTERCEPTORS, MatomoRouterInterceptor } from './interceptor'; import { MatomoRouter } from './matomo-router.service'; import { MATOMO_PAGE_TITLE_PROVIDER, PageTitleProvider } from './page-title-providers'; import { MATOMO_PAGE_URL_PROVIDER, PageUrlProvider } from './page-url-provider'; +import { withRouter } from './providers'; describe('MatomoRouter', () => { let routerEvents: Subject; @@ -22,25 +23,18 @@ describe('MatomoRouter', () => { function instantiate( routerConfig: MatomoRouterConfiguration, - config: Partial, + config: Partial, providers: Provider[] = [], - ): MatomoRouter { + ): { tracker: MatomoTestingTracker; router: MatomoRouter } { TestBed.configureTestingModule({ providers: [ + provideMatomo(config as MatomoConfiguration, withRouter(routerConfig)), { provide: Router, useValue: jasmine.createSpyObj('Router', [], { events: routerEvents, }), }, - { - provide: MATOMO_CONFIGURATION, - useValue: config, - }, - { - provide: MATOMO_ROUTER_CONFIGURATION, - useValue: routerConfig, - }, { provide: MATOMO_PAGE_TITLE_PROVIDER, useValue: jasmine.createSpyObj('PageTitleProvider', { @@ -53,21 +47,12 @@ describe('MatomoRouter', () => { getCurrentPageUrl: of('/custom-url'), }), }, - { - provide: MatomoTracker, - useValue: jasmine.createSpyObj('MatomoTracker', [ - 'setCustomUrl', - 'setDocumentTitle', - 'trackPageView', - 'enableLinkTracking', - 'setReferrerUrl', - ]), - }, + provideTestingTracker(), ...providers, ], }); - return TestBed.inject(MatomoRouter); + return { router: TestBed.inject(MatomoRouter), tracker: TestBed.inject(MatomoTestingTracker) }; } function triggerEvent(url: string): void { @@ -83,148 +68,111 @@ describe('MatomoRouter', () => { it('should track page view with default options', fakeAsync(() => { // Given - const service = instantiate({}, { enableLinkTracking: false }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; - - // Referrer url should be called only AFTER trackPageView - tracker.setReferrerUrl.and.callFake(() => { - expect(tracker.trackPageView).toHaveBeenCalled(); - }); + const { tracker } = instantiate({}, { enableLinkTracking: false }); // When - service.initialize(); triggerEvent('/'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.setCustomUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.setDocumentTitle).toHaveBeenCalledWith('Custom page title'); - expect(tracker.trackPageView).toHaveBeenCalled(); - expect(tracker.setReferrerUrl).toHaveBeenCalledWith('/custom-url'); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should track page view without page title', fakeAsync(() => { // Given - const service = instantiate({ trackPageTitle: false }, { enableLinkTracking: false }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; - - // Referrer url should be called only AFTER trackPageView - tracker.setReferrerUrl.and.callFake(() => { - expect(tracker.trackPageView).toHaveBeenCalled(); - }); + const { tracker } = instantiate({ trackPageTitle: false }, { enableLinkTracking: false }); // When - service.initialize(); triggerEvent('/'); // Then - expect(tracker.setCustomUrl).not.toHaveBeenCalled(); - expect(tracker.setDocumentTitle).not.toHaveBeenCalled(); - expect(tracker.trackPageView).not.toHaveBeenCalled(); - expect(tracker.setReferrerUrl).not.toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([]); // When tick(); // Then - expect(tracker.setCustomUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.setDocumentTitle).not.toHaveBeenCalled(); - expect(tracker.trackPageView).toHaveBeenCalled(); - expect(tracker.setReferrerUrl).toHaveBeenCalledWith('/custom-url'); + expect(tracker.callsAfterInit).toEqual([ + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should track page view synchronously', fakeAsync(() => { // Given - const service = instantiate({ delay: -1 }, { enableLinkTracking: false }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; - - // Referrer url should be called only AFTER trackPageView - tracker.setReferrerUrl.and.callFake(() => { - expect(tracker.trackPageView).toHaveBeenCalled(); - }); + const { tracker } = instantiate({ delay: -1 }, { enableLinkTracking: false }); // When - service.initialize(); triggerEvent('/'); // Then - expect(tracker.setCustomUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.setDocumentTitle).toHaveBeenCalledWith('Custom page title'); - expect(tracker.trackPageView).toHaveBeenCalled(); - expect(tracker.setReferrerUrl).toHaveBeenCalledWith('/custom-url'); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should track page view with some delay', fakeAsync(() => { // Given - const service = instantiate({ delay: 42 }, { enableLinkTracking: false }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; - - // Referrer url should be called only AFTER trackPageView - tracker.setReferrerUrl.and.callFake(() => { - expect(tracker.trackPageView).toHaveBeenCalled(); - }); + const { tracker } = instantiate({ delay: 42 }, { enableLinkTracking: false }); // When - service.initialize(); triggerEvent('/'); tick(41); // Then - expect(tracker.setCustomUrl).not.toHaveBeenCalled(); - expect(tracker.setDocumentTitle).not.toHaveBeenCalled(); - expect(tracker.trackPageView).not.toHaveBeenCalled(); - expect(tracker.setReferrerUrl).not.toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([]); // When tick(1); // Then - expect(tracker.setCustomUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.setDocumentTitle).toHaveBeenCalledWith('Custom page title'); - expect(tracker.trackPageView).toHaveBeenCalled(); - expect(tracker.setReferrerUrl).toHaveBeenCalledWith('/custom-url'); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should track page view with link tracking', fakeAsync(() => { // Given - const service = instantiate({}, { enableLinkTracking: true }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; - - // Referrer url should be called only AFTER trackPageView - tracker.setReferrerUrl.and.callFake(() => { - expect(tracker.trackPageView).toHaveBeenCalled(); - }); + const { tracker } = instantiate({}, { enableLinkTracking: true }); // When - service.initialize(); triggerEvent('/'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.setCustomUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.setDocumentTitle).toHaveBeenCalledWith('Custom page title'); - expect(tracker.trackPageView).toHaveBeenCalled(); - expect(tracker.setReferrerUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.enableLinkTracking).toHaveBeenCalledWith(false); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['enableLinkTracking', false], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should track page view with link tracking using pseudo-clicks', fakeAsync(() => { // Given - const service = instantiate({}, { enableLinkTracking: 'enable-pseudo' }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; - - // Referrer url should be called only AFTER trackPageView - tracker.setReferrerUrl.and.callFake(() => { - expect(tracker.trackPageView).toHaveBeenCalled(); - }); + const { tracker } = instantiate({}, { enableLinkTracking: 'enable-pseudo' }); // When - service.initialize(); triggerEvent('/'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.setCustomUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.setDocumentTitle).toHaveBeenCalledWith('Custom page title'); - expect(tracker.trackPageView).toHaveBeenCalled(); - expect(tracker.setReferrerUrl).toHaveBeenCalledWith('/custom-url'); - expect(tracker.enableLinkTracking).toHaveBeenCalledWith(true); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['enableLinkTracking', true], + ['setReferrerUrl', '/custom-url'], + ]); })); function expectExcludedUrls( @@ -233,27 +181,32 @@ describe('MatomoRouter', () => { expected: string[], ): void { // Given - const service = instantiate({ exclude: config }, { enableLinkTracking: true }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; + const { tracker } = instantiate({ exclude: config }, { enableLinkTracking: true }); const urlProvider = TestBed.inject(MATOMO_PAGE_URL_PROVIDER) as jasmine.SpyObj; urlProvider.getCurrentPageUrl.and.callFake(event => of(event.urlAfterRedirects)); // When - service.initialize(); events.forEach(triggerEvent); tick(); // Tracking is asynchronous by default // Then expected.forEach(expectedUrl => { - expect(tracker.setCustomUrl).toHaveBeenCalledWith(expectedUrl); + expect(tracker.callsAfterInit).toEqual( + jasmine.arrayContaining([['setCustomUrl', expectedUrl]]), + ); + // expect(tracker.setCustomUrl).toHaveBeenCalledWith(expectedUrl); }); events .filter(url => !expected.includes(url)) .forEach(excludedUrl => { - expect(tracker.setCustomUrl).not.toHaveBeenCalledWith(excludedUrl); + expect(tracker.callsAfterInit).not.toEqual( + jasmine.arrayContaining([['setCustomUrl', excludedUrl]]), + ); + // expect(tracker.setCustomUrl).not.toHaveBeenCalledWith(excludedUrl); }); - expect(tracker.trackPageView).toHaveBeenCalledTimes(expected.length); + expect(tracker.countCallsAfterInit('setCustomUrl')).toEqual(expected.length); + // expect(tracker.trackPageView).toHaveBeenCalledTimes(expected.length); } it('should track page view with single url filter', fakeAsync(() => { @@ -282,19 +235,14 @@ describe('MatomoRouter', () => { it('should not track page view if disabled', fakeAsync(() => { // Given - const service = instantiate({}, { disabled: true, enableLinkTracking: false }); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; + const { tracker } = instantiate({}, { disabled: true, enableLinkTracking: false }); // When - service.initialize(); triggerEvent('/'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.setCustomUrl).not.toHaveBeenCalled(); - expect(tracker.setDocumentTitle).not.toHaveBeenCalled(); - expect(tracker.trackPageView).not.toHaveBeenCalled(); - expect(tracker.setReferrerUrl).not.toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([]); })); it('should track page view if in browser', fakeAsync(() => { @@ -302,19 +250,18 @@ describe('MatomoRouter', () => { const interceptor = jasmine.createSpyObj('interceptor', [ 'beforePageTrack', ]); - const service = instantiate({}, {}, [ + + const { tracker } = instantiate({}, {}, [ { provide: PLATFORM_ID, useValue: ɵPLATFORM_BROWSER_ID }, { provide: MATOMO_ROUTER_INTERCEPTORS, multi: true, useValue: interceptor }, ]); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; // When - service.initialize(); triggerEvent('/'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.trackPageView).toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual(jasmine.arrayContaining([['trackPageView', undefined]])); expect(interceptor.beforePageTrack).toHaveBeenCalled(); })); @@ -323,58 +270,69 @@ describe('MatomoRouter', () => { const interceptor = jasmine.createSpyObj('interceptor', [ 'beforePageTrack', ]); - const service = instantiate({}, {}, [ + const { tracker } = instantiate({}, {}, [ { provide: PLATFORM_ID, useValue: ɵPLATFORM_SERVER_ID }, { provide: MATOMO_ROUTER_INTERCEPTORS, multi: true, useValue: interceptor }, ]); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; // When - service.initialize(); triggerEvent('/'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.trackPageView).not.toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([]); expect(interceptor.beforePageTrack).not.toHaveBeenCalled(); })); it('should track page view if navigated to the same url with different query params', fakeAsync(() => { // Given - const service = instantiate( + const { tracker } = instantiate( { navigationEndComparator: 'fullUrl', }, { enableLinkTracking: false }, ); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; // When - service.initialize(); triggerEvent('/test'); triggerEvent('/test?page=1'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.trackPageView).toHaveBeenCalledTimes(2); + expect(tracker.callsAfterInit).toEqual([ + // First call + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + + // Second call + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should not track page view if navigated to the same url with query params', fakeAsync(() => { // Given - const service = instantiate( + const { tracker } = instantiate( { navigationEndComparator: 'ignoreQueryParams' }, { enableLinkTracking: false }, ); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; // When - service.initialize(); triggerEvent('/test'); triggerEvent('/test?page=1'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.trackPageView).toHaveBeenCalledTimes(1); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should not track page view if navigated to the "same" url, as configured from custom NavigationEndComparator', fakeAsync(() => { @@ -394,23 +352,34 @@ describe('MatomoRouter', () => { isEvenPageParam(currentNavigationEnd.urlAfterRedirects) ); }; - const service = instantiate( + + const { tracker } = instantiate( { navigationEndComparator: myCustomComparator, }, { enableLinkTracking: false }, ); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; // When - service.initialize(); triggerEvent('/test?page=1'); triggerEvent('/test?page=2'); triggerEvent('/test?page=4'); tick(); // Tracking is asynchronous by default // Then - expect(tracker.trackPageView).toHaveBeenCalledTimes(2); + expect(tracker.callsAfterInit).toEqual([ + // First call + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + + // Second call + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should call interceptors if any and wait for them to resolve', fakeAsync(() => { @@ -427,18 +396,19 @@ describe('MatomoRouter', () => { const interceptor3 = jasmine.createSpyObj('interceptor3', { beforePageTrack: interceptor3Subject, }); - const service = instantiate({ delay: -1 }, { enableLinkTracking: false }, [ + const { tracker } = instantiate({ delay: -1 }, { enableLinkTracking: false }, [ { provide: MATOMO_ROUTER_INTERCEPTORS, multi: true, useValue: interceptor1 }, { provide: MATOMO_ROUTER_INTERCEPTORS, multi: true, useValue: interceptor2 }, { provide: MATOMO_ROUTER_INTERCEPTORS, multi: true, useValue: interceptor3 }, ]); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; // When - service.initialize(); triggerEvent('/'); // Then - expect(tracker.trackPageView).not.toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ]); expect(interceptor1.beforePageTrack).toHaveBeenCalled(); expect(interceptor2.beforePageTrack).toHaveBeenCalled(); expect(interceptor3.beforePageTrack).toHaveBeenCalled(); @@ -447,13 +417,21 @@ describe('MatomoRouter', () => { interceptor3Subject.next(); interceptor3Subject.complete(); // Then - expect(tracker.trackPageView).not.toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ]); // When interceptor2Resolve!(); flush(); // Then - expect(tracker.trackPageView).toHaveBeenCalled(); + expect(tracker.callsAfterInit).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should throw an error when interceptors are not declared as multi provider', fakeAsync(() => { @@ -501,10 +479,9 @@ describe('MatomoRouter', () => { const slowInterceptor = jasmine.createSpyObj('slowInterceptor', [ 'beforePageTrack', ]); - const service = instantiate({ delay: -1 }, { enableLinkTracking: false }, [ + const { tracker } = instantiate({ delay: -1 }, { enableLinkTracking: false }, [ { provide: MATOMO_ROUTER_INTERCEPTORS, multi: true, useValue: slowInterceptor }, ]); - const tracker = TestBed.inject(MatomoTracker) as jasmine.SpyObj; slowInterceptor.beforePageTrack.and.returnValues( slowInterceptorPromise1, @@ -513,13 +490,19 @@ describe('MatomoRouter', () => { ); // When - service.initialize(); triggerEvent('/page1'); triggerEvent('/page2'); triggerEvent('/page3'); slowInterceptorResolve2!(); // Resolve #2 first // Then - expect(tracker.trackPageView).not.toHaveBeenCalled(); + expect(tracker.calls).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ]); expect(slowInterceptor.beforePageTrack).toHaveBeenCalledTimes(1); // When @@ -527,26 +510,50 @@ describe('MatomoRouter', () => { flush(); // Then expect(slowInterceptor.beforePageTrack).toHaveBeenCalledTimes(3); - expect(tracker.trackPageView).toHaveBeenCalledTimes(2); + expect(tracker.calls).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); // When slowInterceptorResolve3!(); // Resolve #3 flush(); // Then expect(slowInterceptor.beforePageTrack).toHaveBeenCalledTimes(3); - expect(tracker.trackPageView).toHaveBeenCalledTimes(3); + expect(tracker.calls).toEqual([ + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['setDocumentTitle', 'Custom page title'], + ['setCustomUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ['trackPageView', undefined], + ['setReferrerUrl', '/custom-url'], + ]); })); it('should map deprecated init() method to initialize()', () => { // Given - const service = instantiate({}, {}); + const { router } = instantiate({}, {}); - spyOn(service, 'initialize'); + spyOn(router, 'initialize'); // When - service.init(); + router.init(); // Then - expect(service.initialize).toHaveBeenCalledOnceWith(); + expect(router.initialize).toHaveBeenCalledOnceWith(); }); }); diff --git a/projects/ngx-matomo-client/router/matomo-router.service.ts b/projects/ngx-matomo-client/router/matomo-router.service.ts index b12024c..d5c0176 100644 --- a/projects/ngx-matomo-client/router/matomo-router.service.ts +++ b/projects/ngx-matomo-client/router/matomo-router.service.ts @@ -76,7 +76,7 @@ function getNavigationEndComparator(config: InternalRouterConfiguration): Naviga } } -@Injectable({ providedIn: 'root' }) +@Injectable() export class MatomoRouter { constructor( private readonly router: Router, diff --git a/projects/ngx-matomo-client/router/page-url-provider.spec.ts b/projects/ngx-matomo-client/router/page-url-provider.spec.ts index b4691b7..cba1556 100644 --- a/projects/ngx-matomo-client/router/page-url-provider.spec.ts +++ b/projects/ngx-matomo-client/router/page-url-provider.spec.ts @@ -1,9 +1,10 @@ import { APP_BASE_HREF, LocationStrategy } from '@angular/common'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd } from '@angular/router'; -import { MATOMO_CONFIGURATION, MatomoConfiguration } from 'ngx-matomo-client/core'; -import { MATOMO_ROUTER_CONFIGURATION, MatomoRouterConfiguration } from './configuration'; +import { MatomoConfiguration, provideMatomo } from 'ngx-matomo-client/core'; +import { MatomoRouterConfiguration } from './configuration'; import { MATOMO_PAGE_URL_PROVIDER, PageUrlProvider } from './page-url-provider'; +import { withRouter } from './providers'; describe('PageUrlProvider', () => { function instantiate( @@ -12,14 +13,7 @@ describe('PageUrlProvider', () => { ): PageUrlProvider { TestBed.configureTestingModule({ providers: [ - { - provide: MATOMO_CONFIGURATION, - useValue: {} as MatomoConfiguration, - }, - { - provide: MATOMO_ROUTER_CONFIGURATION, - useValue: config, - }, + provideMatomo({} as MatomoConfiguration, withRouter(config ?? {})), { provide: APP_BASE_HREF, useValue: baseHref, diff --git a/projects/ngx-matomo-client/router/providers.ts b/projects/ngx-matomo-client/router/providers.ts index c444165..ab0b74a 100644 --- a/projects/ngx-matomo-client/router/providers.ts +++ b/projects/ngx-matomo-client/router/providers.ts @@ -32,6 +32,7 @@ export const enum RouterMatomoFeatureKind { /** Enable automatic page views tracking */ export function withRouter(config?: MatomoRouterConfiguration): MatomoFeature { const providers = [ + MatomoRouter, { provide: MATOMO_ROUTER_ENABLED, useValue: true }, { provide: MATOMO_ROUTER_CONFIGURATION, useValue: config }, {