diff --git a/.github/scripts/check-coverage-thresholds.js b/.github/scripts/check-coverage-thresholds.js index 6eb7a39e7..02c3995fd 100644 --- a/.github/scripts/check-coverage-thresholds.js +++ b/.github/scripts/check-coverage-thresholds.js @@ -1,5 +1,4 @@ const fs = require('fs'); -const { execSync } = require('child_process'); const coverage = require('../../coverage/coverage-summary.json'); const jestConfig = require('../../jest.config.js'); @@ -41,7 +40,6 @@ for (const key of ['branches', 'functions', 'lines', 'statements']) { if (failed) { const stars = '*'.repeat(warnMessage.length + 8); - execSync('clear', { stdio: 'inherit' }); console.log('\n\nCongratulations! You have successfully run the coverage check and added tests.'); console.log('\n\nThe jest.config.js file is not insync with your new test additions.'); console.log('Please update the coverage thresholds in jest.config.js.'); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e41d3893..75fc25cd7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,11 +5,29 @@ on: branches: ['*'] # or change to match your default branch push: branches: ['*'] + workflow_dispatch: jobs: test: runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.13.1 + + - name: Install dependencies + run: npm ci + + - name: Run Jest tests + run: npm run ci:test + test-coverage: + runs-on: ubuntu-latest + steps: - name: Checkout code uses: actions/checkout@v3 @@ -23,7 +41,27 @@ jobs: run: npm ci - name: Run Jest tests with coverage - run: npm run ci + run: npm run ci:test:coverage - name: Check coverage thresholds run: npm run test:check-coverage-thresholds + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.13.1 + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint + + - name: Check formatting with prettier + run: npm run format:check diff --git a/.gitignore b/.gitignore index 2f85265bd..fca5b5041 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /tmp /out-tsc /bazel-out +/src/assets/config/config.json # Node /node_modules diff --git a/README.md b/README.md index b2c6bb3e9..8b4fbb3be 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ take up to 60 seconds once the docker build finishes. - Install git commit template: [Commit Template](docs/commit.template.md). - [Volta](#volta) +- 3rd-party tokens [Configuration](#configuration) ### Recommended @@ -59,3 +60,9 @@ npm run test:check-coverage-thresholds OSF uses volta to manage node and npm versions inside of the repository Install Volta from [volta](https://volta.sh/) and it will automatically pin Node/npm per the repo toolchain. + +## Configuration + +OSF uses an `assets/config/config.json` file for any 3rd-party tokens. This file is not committed to the repo. + +There is a `assets/config/template.json` file that can be copied to `assets/config/config.json` to store any 3rd-party tokens locally. diff --git a/jest.config.js b/jest.config.js index ccef4bc7e..c7511d422 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { '^@osf/(.*)$': '/src/app/$1', '^@core/(.*)$': '/src/app/core/$1', '^@shared/(.*)$': '/src/app/shared/$1', - '^@styles/(.*)$': '/src/styles/$1', + '^@styles/(.*)$': '/assets/styles/$1', '^@testing/(.*)$': '/src/testing/$1', '^src/environments/environment$': '/src/environments/environment.ts', }, @@ -62,24 +62,13 @@ module.exports = { testPathIgnorePatterns: [ '/src/app/app.config.ts', '/src/app/app.routes.ts', - '/src/app/features/registry/', - '/src/app/features/project/addons/components/configure-configure-addon/', - '/src/app/features/project/addons/components/connect-configured-addon/', - '/src/app/features/project/addons/components/disconnect-addon-modal/', - '/src/app/features/project/addons/components/confirm-account-connection-modal/', '/src/app/features/files/components', - '/src/app/features/files/pages/community-metadata', '/src/app/features/files/pages/file-detail', '/src/app/features/preprints/', - '/src/app/features/project/contributors/', - '/src/app/features/project/overview/', - '/src/app/features/project/registrations', - '/src/app/features/project/settings', - '/src/app/features/project/wiki', - '/src/app/features/project/project.component.ts', + '/src/app/features/project/', '/src/app/features/registries/', + '/src/app/features/registry/', '/src/app/features/settings/addons/', - '/src/app/features/settings/tokens/mappers/', '/src/app/features/settings/tokens/store/', '/src/app/shared/components/file-menu/', '/src/app/shared/components/files-tree/', @@ -88,7 +77,6 @@ module.exports = { '/src/app/shared/components/pie-chart/', '/src/app/shared/components/resource-citations/', '/src/app/shared/components/reusable-filter/', - '/src/app/shared/components/subjects/', '/src/app/shared/components/wiki/edit-section/', '/src/app/shared/components/wiki/wiki-list/', ], diff --git a/package-lock.json b/package-lock.json index e5bd9aae8..06d90c170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,9 @@ "@ngxs/logger-plugin": "^19.0.0", "@ngxs/store": "^19.0.0", "@primeng/themes": "^19.0.9", + "@sentry/angular": "^10.10.0", "ace-builds": "^1.42.0", + "angular-google-tag-manager": "^1.11.0", "cedar-artifact-viewer": "^0.9.5", "cedar-embeddable-editor": "^1.5.0", "chart.js": "^4.4.9", @@ -7408,6 +7410,101 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.10.0.tgz", + "integrity": "sha512-209QN9vsQBwJcS+9DU7B4yl9mb4OqCt2kdL3LYDvqsuOdpICpwfowdK3RMn825Ruf4KLJa0KHM1scQbXZCc4lw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.10.0.tgz", + "integrity": "sha512-oSU4F/ebOsJA9Eof0me9hLpSDTSelpnEY6gmhU9sHyIG+U7hJRuCfeGICxQOzBtteepWRhAaZEv4s9ZBh3iD2w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.10.0.tgz", + "integrity": "sha512-sKFYWBaft0ET6gd5B0pThR6gYTjaUECXCzVAnSYxy64a2/PK6lV93BtnA1C2Q34Yhv/0scdyIbZtfTnSsEgwUg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.10.0", + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.10.0.tgz", + "integrity": "sha512-mJBNB0EBbE3vzL7lgd8lDoWWhRaRwxXdI4Kkx3r39u2+1qTdJP/xHbJDihyemCaw7gRL1FR/GC44JLipzEfkKQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.10.0", + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/angular": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-10.10.0.tgz", + "integrity": "sha512-QlaVlkZHwAsZGWaWbCKAwrjFHB78IbExybVGl4wpuaJtZHUm7hS595jndTNeMW7yOjTXGINTlW5xRiSuuZ3tlw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.10.0", + "@sentry/core": "10.10.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@angular/common": ">= 14.x <= 20.x", + "@angular/core": ">= 14.x <= 20.x", + "@angular/router": ">= 14.x <= 20.x", + "rxjs": "^6.5.5 || ^7.x" + } + }, + "node_modules/@sentry/browser": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.10.0.tgz", + "integrity": "sha512-STBs29meUk0CvluIOXXnnRGRtjKsJN9fAHS3dUu3GMjmow4rxKBiBbAwoPYftAVdfvGypT7zQCQ+K30dbRxp0g==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.10.0", + "@sentry-internal/feedback": "10.10.0", + "@sentry-internal/replay": "10.10.0", + "@sentry-internal/replay-canvas": "10.10.0", + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.10.0.tgz", + "integrity": "sha512-4O1O6my/vYE98ZgfEuLEwOOuHzqqzfBT6IdRo1yiQM7/AXcmSl0H/k4HJtXCiCTiHm+veEuTDBHp0GQZmpIbtA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@sigstore/bundle": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", @@ -8989,6 +9086,19 @@ "typescript-eslint": "^8.0.0" } }, + "node_modules/angular-google-tag-manager": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/angular-google-tag-manager/-/angular-google-tag-manager-1.11.0.tgz", + "integrity": "sha512-r9sHS+LO9LUoQsiqPo05yTfGRpA3oODc/0AmL0QA1SbeboHKBkCRZIUHkv5w6+GGmWR/G+ZR52eHNLWcgTwIAA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0", + "@angular/compiler": "^19.0.0" + } + }, "node_modules/angularx-qrcode": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-19.0.0.tgz", diff --git a/package.json b/package.json index b2aa7044e..e1aaa80be 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", "build": "ng build", - "ci": "npm run lint && jest --coverage", + "ci:test": "jest", + "ci:test:coverage": "jest --coverage", "docs": "./node_modules/.bin/compodoc -p tsconfig.docs.json --name 'OSF Angular Documentation' --theme 'laravel' -s", "docs:coverage": "./node_modules/.bin/compodoc -p tsconfig.docs.json --coverageTest 0 --coverageMinimumPerFile 0", "lint": "ng lint", @@ -45,7 +46,9 @@ "@ngxs/logger-plugin": "^19.0.0", "@ngxs/store": "^19.0.0", "@primeng/themes": "^19.0.9", + "@sentry/angular": "^10.10.0", "ace-builds": "^1.42.0", + "angular-google-tag-manager": "^1.11.0", "cedar-artifact-viewer": "^0.9.5", "cedar-embeddable-editor": "^1.5.0", "chart.js": "^4.4.9", diff --git a/src/app/app.component.html b/src/app/app.component.html index 4997c5280..37294ebc1 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,4 @@ + diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 3edc6c1e5..a7b38e60e 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -2,51 +2,105 @@ import { provideStore, Store } from '@ngxs/store'; import { MockComponents } from 'ng-mocks'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Subject } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { NavigationEnd, Router } from '@angular/router'; +import { OSFConfigService } from '@core/services/osf-config.service'; import { GetCurrentUser, UserState } from '@core/store/user'; import { UserEmailsState } from '@core/store/user-emails'; -import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; +import { CookieConsentComponent, FullScreenLoaderComponent, ToastComponent } from './shared/components'; import { TranslateServiceMock } from './shared/mocks'; import { AppComponent } from './app.component'; -describe('AppComponent', () => { - let component: AppComponent; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { GoogleTagManagerService } from 'angular-google-tag-manager'; + +describe('Component: App', () => { + let routerEvents$: Subject; + let gtmServiceMock: jest.Mocked; + let osfConfigServiceMock: OSFConfigService; let fixture: ComponentFixture; beforeEach(async () => { + routerEvents$ = new Subject(); + + gtmServiceMock = { + pushTag: jest.fn(), + } as any; + await TestBed.configureTestingModule({ - imports: [AppComponent, ...MockComponents(ToastComponent, FullScreenLoaderComponent)], + imports: [ + OSFTestingModule, + AppComponent, + ...MockComponents(ToastComponent, FullScreenLoaderComponent, CookieConsentComponent), + ], providers: [ provideStore([UserState, UserEmailsState]), - provideHttpClient(), - provideHttpClientTesting(), TranslateServiceMock, + { provide: GoogleTagManagerService, useValue: gtmServiceMock }, + { + provide: Router, + useValue: { + events: routerEvents$.asObservable(), + }, + }, + { + provide: OSFConfigService, + useValue: { + has: jest.fn(), + }, + }, ], }).compileComponents(); + osfConfigServiceMock = TestBed.inject(OSFConfigService); fixture = TestBed.createComponent(AppComponent); - component = fixture.componentInstance; - fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + describe('detect changes', () => { + beforeEach(() => { + fixture.detectChanges(); + }); - it('should dispatch GetCurrentUser action on initialization', () => { - const store = TestBed.inject(Store); - const dispatchSpy = jest.spyOn(store, 'dispatch'); - store.dispatch(GetCurrentUser); - expect(dispatchSpy).toHaveBeenCalledWith(GetCurrentUser); + it('should dispatch GetCurrentUser action on initialization', () => { + const store = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + store.dispatch(GetCurrentUser); + expect(dispatchSpy).toHaveBeenCalledWith(GetCurrentUser); + }); + + it('should render router outlet', () => { + const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); + expect(routerOutlet).toBeTruthy(); + }); }); - it('should render router outlet', () => { - const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); - expect(routerOutlet).toBeTruthy(); + describe('Google Tag Manager', () => { + it('should push GTM tag on NavigationEnd with google tag id', () => { + jest.spyOn(osfConfigServiceMock, 'has').mockReturnValue(true); + fixture.detectChanges(); + const event = new NavigationEnd(1, '/previous', '/current'); + + routerEvents$.next(event); + + expect(gtmServiceMock.pushTag).toHaveBeenCalledWith({ + event: 'page', + pageName: '/current', + }); + }); + + it('should not push GTM tag on NavigationEnd with google tag id', () => { + jest.spyOn(osfConfigServiceMock, 'has').mockReturnValue(false); + fixture.detectChanges(); + const event = new NavigationEnd(1, '/previous', '/current'); + + routerEvents$.next(event); + + expect(gtmServiceMock.pushTag).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index afba7d027..a40f124f0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,40 +4,43 @@ import { TranslateService } from '@ngx-translate/core'; import { DialogService } from 'primeng/dynamicdialog'; -import { filter } from 'rxjs/operators'; +import { filter } from 'rxjs'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { OSFConfigService } from '@core/services/osf-config.service'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { ConfirmEmailComponent } from '@shared/components'; +import { CookieConsentComponent } from '@shared/components/cookie-consent/cookie-consent.component'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; -import { MetaTagsService } from './shared/services'; + +import { GoogleTagManagerService } from 'angular-google-tag-manager'; @Component({ selector: 'osf-root', - imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent], + imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent, CookieConsentComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, providers: [DialogService], }) export class AppComponent implements OnInit { + private readonly googleTagManagerService = inject(GoogleTagManagerService); private readonly destroyRef = inject(DestroyRef); private readonly dialogService = inject(DialogService); private readonly router = inject(Router); private readonly translateService = inject(TranslateService); - private readonly metaTagsService = inject(MetaTagsService); + private readonly osfConfigService = inject(OSFConfigService); private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); constructor() { - this.setupMetaTagsCleanup(); effect(() => { if (this.unverifiedEmails().length) { this.showEmailDialog(); @@ -48,15 +51,20 @@ export class AppComponent implements OnInit { ngOnInit(): void { this.actions.getCurrentUser(); this.actions.getEmails(); - } - private setupMetaTagsCleanup(): void { - this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((event: NavigationEnd) => this.metaTagsService.clearMetaTagsIfNeeded(event.url)); + if (this.osfConfigService.has('googleTagManagerId')) { + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.googleTagManagerService.pushTag({ + event: 'page', + pageName: event.urlAfterRedirects, + }); + }); + } } private showEmailDialog() { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4f619d1f6..a606ce22b 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -12,13 +12,15 @@ import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { STATES } from '@core/constants'; +import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/factory/application.initialization.factory'; import { provideTranslation } from '@core/helpers'; -import { GlobalErrorHandler } from './core/handlers'; import { authInterceptor, errorInterceptor, viewOnlyInterceptor } from './core/interceptors'; import CustomPreset from './core/theme/custom-preset'; import { routes } from './app.routes'; +import * as Sentry from '@sentry/angular'; + export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), @@ -41,6 +43,11 @@ export const appConfig: ApplicationConfig = { importProvidersFrom(TranslateModule.forRoot(provideTranslation())), ConfirmationService, MessageService, - { provide: ErrorHandler, useClass: GlobalErrorHandler }, + + APPLICATION_INITIALIZATION_PROVIDER, + { + provide: ErrorHandler, + useFactory: () => Sentry.createErrorHandler({ showDialog: false }), + }, ], }; diff --git a/src/app/core/components/footer/footer.component.html b/src/app/core/components/footer/footer.component.html index 75019bbb2..48c5b94d9 100644 --- a/src/app/core/components/footer/footer.component.html +++ b/src/app/core/components/footer/footer.component.html @@ -1,7 +1,7 @@