diff --git a/README.md b/README.md index df2794937..a7f9f35be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Osf +# OSF This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.0. diff --git a/angular.json b/angular.json index fb981a90c..70e0eec2a 100644 --- a/angular.json +++ b/angular.json @@ -88,17 +88,9 @@ "builder": "@angular-devkit/build-angular:extract-i18n" }, "test": { - "builder": "@angular-devkit/build-angular:karma", + "builder": "@angular-devkit/build-angular:jest", "options": { - "polyfills": ["zone.js", "zone.js/testing"], - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": ["src/assets"], - "styles": ["src/assets/styles/styles.scss"], - "stylePreprocessorOptions": { - "includePaths": ["src"] - }, - "scripts": [] + "tsConfig": "tsconfig.spec.json" } }, "lint": { diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..72905bd2d --- /dev/null +++ b/jest.config.js @@ -0,0 +1,30 @@ +module.exports = { + preset: 'jest-preset-angular', + setupFilesAfterEnv: ['/setup-jest.ts'], + moduleNameMapper: { + '^@osf/(.*)$': '/src/app/$1', + '^@core/(.*)$': '/src/app/core/$1', + '^@shared/(.*)$': '/src/app/shared/$1', + '^@styles/(.*)$': '/assets/styles/$1', + }, + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + testEnvironment: 'jsdom', + moduleFileExtensions: ['ts', 'js', 'html', 'json', 'mjs'], + coverageDirectory: 'coverage', + collectCoverageFrom: [ + 'src/app/**/*.{ts,js}', + '!src/app/**/*.spec.{ts,js}', + '!src/app/**/*.module.ts', + '!src/app/**/index.ts', + '!src/app/**/public-api.ts', + ], +}; diff --git a/package.json b/package.json index b1fb161c5..75592ea3c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", "lint": "eslint . --ext .ts,.html", "format": "prettier --write \"src/**/*.{ts,html,scss}\"", "prettier:check": "prettier --check \"src/**/*.{ts,html,scss}\"", @@ -48,6 +50,7 @@ "@commitlint/config-conventional": "^19.7.1", "@types/bun": "latest", "@types/jasmine": "~5.1.0", + "@types/jest": "^29.5.14", "angular-eslint": "19.1.0", "eslint": "^9.20.0", "eslint-config-prettier": "^10.1.5", @@ -58,13 +61,12 @@ "fantasticon": "^3.0.0", "husky": "^9.1.7", "jasmine-core": "~5.6.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", + "jest": "^29.7.0", + "jest-preset-angular": "^14.5.5", "lint-staged": "^15.4.3", + "ng-mocks": "^14.13.4", "prettier": "3.5.2", + "ts-jest": "^29.3.2", "typescript": "~5.7.2", "typescript-eslint": "8.23.0" }, diff --git a/setup-jest.ts b/setup-jest.ts new file mode 100644 index 000000000..dbb3d6b96 --- /dev/null +++ b/setup-jest.ts @@ -0,0 +1,38 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv(); + +// Global mocks for jsdom +const mock = () => { + let storage: Record = {}; + return { + getItem: (key: string) => (key in storage ? storage[key] : null), + setItem: (key: string, value: string) => (storage[key] = value || ''), + removeItem: (key: string) => delete storage[key], + clear: () => (storage = {}), + }; +}; + +Object.defineProperty(window, 'localStorage', { value: mock() }); +Object.defineProperty(window, 'sessionStorage', { value: mock() }); +Object.defineProperty(window, 'getComputedStyle', { + value: () => ['-webkit-appearance'], +}); + +Object.defineProperty(document.body, 'clientWidth', { value: 1024 }); +Object.defineProperty(document.body, 'clientHeight', { value: 768 }); + +// Mock ResizeObserver for Jest (PrimeNG and other UI libs may require this) +class ResizeObserver { + // eslint-disable-next-line @typescript-eslint/no-empty-function + observe() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + unobserve() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + disconnect() {} +} +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + configurable: true, + value: ResizeObserver, +}); diff --git a/src/app/core/components/header/header.component.spec.ts b/src/app/core/components/header/header.component.spec.ts index cf5779579..275e7389a 100644 --- a/src/app/core/components/header/header.component.spec.ts +++ b/src/app/core/components/header/header.component.spec.ts @@ -1,4 +1,10 @@ +import { NgxsModule } from '@ngxs/store'; + +import { provideHttpClient, withFetch } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { UserState } from '@osf/core/store/user'; import { HeaderComponent } from './header.component'; @@ -8,7 +14,8 @@ describe('HeaderComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [HeaderComponent], + imports: [HeaderComponent, NgxsModule.forRoot([UserState])], + providers: [provideRouter([]), provideHttpClient(withFetch())], }).compileComponents(); fixture = TestBed.createComponent(HeaderComponent); diff --git a/src/app/core/components/root/root.component.spec.ts b/src/app/core/components/root/root.component.spec.ts index ab6a02c68..c496ed2a3 100644 --- a/src/app/core/components/root/root.component.spec.ts +++ b/src/app/core/components/root/root.component.spec.ts @@ -19,14 +19,4 @@ describe('RootComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); - - it('should detect portrait mode correctly', () => { - spyOnProperty(window, 'innerHeight').and.returnValue(1000); - spyOnProperty(window, 'innerWidth').and.returnValue(800); - expect(component.isPortrait()).toBeTrue(); - - spyOnProperty(window, 'innerHeight').and.returnValue(800); - spyOnProperty(window, 'innerWidth').and.returnValue(1000); - expect(component.isPortrait()).toBeFalse(); - }); }); diff --git a/src/app/features/home/home.component.spec.ts b/src/app/features/home/home.component.spec.ts index 7737f1c89..3a7f10a9f 100644 --- a/src/app/features/home/home.component.spec.ts +++ b/src/app/features/home/home.component.spec.ts @@ -1,4 +1,13 @@ +import { NgxsModule } from '@ngxs/store'; + +import { TranslateModule, TranslateStore } from '@ngx-translate/core'; + +import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { MyProjectsState } from '@osf/features/my-projects/store/my-projects.state'; import { HomeComponent } from './home.component'; @@ -8,7 +17,8 @@ describe('HomeComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [HomeComponent], + imports: [HomeComponent, NgxsModule.forRoot([MyProjectsState]), TranslateModule.forRoot()], + providers: [provideRouter([]), provideHttpClient(withFetch()), provideHttpClientTesting(), TranslateStore], }).compileComponents(); fixture = TestBed.createComponent(HomeComponent); diff --git a/src/app/features/search/search.component.spec.ts b/src/app/features/search/search.component.spec.ts index 69681568f..da3cb0774 100644 --- a/src/app/features/search/search.component.spec.ts +++ b/src/app/features/search/search.component.spec.ts @@ -1,15 +1,49 @@ +import { NgxsModule, Store } from '@ngxs/store'; + +import { of } from 'rxjs'; + +import { provideHttpClient, withFetch } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ResourceFiltersState } from '@shared/components/resources/resource-filters/store'; +import { ResourcesWrapperComponent } from '@shared/components/resources/resources-wrapper/resources-wrapper.component'; +import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; +import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; + import { SearchComponent } from './search.component'; +import { SearchState } from './store'; + +import { MockComponent } from 'ng-mocks'; describe('SearchComponent', () => { let component: SearchComponent; let fixture: ComponentFixture; + let store: Store; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SearchComponent], - }).compileComponents(); + imports: [SearchComponent, NgxsModule.forRoot([SearchState, ResourceFiltersState])], + providers: [ + provideHttpClient(withFetch()), + provideHttpClientTesting(), + { provide: IS_XSMALL, useValue: of(false) }, + ], + }) + .overrideComponent(SearchComponent, { + remove: { + imports: [SearchInputComponent, ResourcesWrapperComponent], + }, + add: { + imports: [MockComponent(SearchInputComponent), MockComponent(ResourcesWrapperComponent)], + }, + }) + .compileComponents(); + + store = TestBed.inject(Store); + jest.spyOn(store, 'selectSignal').mockReturnValue(signal('')); + jest.spyOn(store, 'dispatch').mockReturnValue(of(undefined)); fixture = TestBed.createComponent(SearchComponent); component = fixture.componentInstance; diff --git a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.spec.ts b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.spec.ts index bebf34b45..86566d066 100644 --- a/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.spec.ts +++ b/src/app/features/settings/developer-apps/developer-app-details/developer-app-details.component.spec.ts @@ -1,14 +1,29 @@ +import { NgxsModule } from '@ngxs/store'; + +import { ConfirmationService } from 'primeng/api'; + +import { of } from 'rxjs'; + +import { provideHttpClient, withFetch } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { DeveloperAppsState } from '../store'; import { DeveloperAppDetailsComponent } from './developer-app-details.component'; -describe('DeveloperApplicationDetailsComponent', () => { +describe('DeveloperAppDetailsComponent', () => { let component: DeveloperAppDetailsComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [DeveloperAppDetailsComponent], + imports: [DeveloperAppDetailsComponent, NgxsModule.forRoot([DeveloperAppsState])], + providers: [ + ConfirmationService, + provideHttpClient(withFetch()), + { provide: ActivatedRoute, useValue: { params: of({}) } }, + ], }).compileComponents(); fixture = TestBed.createComponent(DeveloperAppDetailsComponent); diff --git a/src/app/features/settings/tokens/tokens-list/tokens-list.component.spec.ts b/src/app/features/settings/tokens/tokens-list/tokens-list.component.spec.ts index a4175341c..3dec1e255 100644 --- a/src/app/features/settings/tokens/tokens-list/tokens-list.component.spec.ts +++ b/src/app/features/settings/tokens/tokens-list/tokens-list.component.spec.ts @@ -1,14 +1,65 @@ +import { Store } from '@ngxs/store'; + +import { Confirmation, ConfirmationService } from 'primeng/api'; + +import { BehaviorSubject } from 'rxjs'; + +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { Token } from '@osf/features/settings/tokens/entities/tokens.models'; +import { IS_XSMALL } from '@shared/utils/breakpoints.tokens'; import { TokensListComponent } from './tokens-list.component'; describe('TokensListComponent', () => { let component: TokensListComponent; let fixture: ComponentFixture; + let store: jasmine.SpyObj; + let confirmationService: jasmine.SpyObj; + let isXSmall$: BehaviorSubject; + + const mockTokens: Token[] = [ + { + id: '1', + name: 'Test Token 1', + tokenId: 'token1', + scopes: ['read', 'write'], + ownerId: 'user1', + }, + { + id: '2', + name: 'Test Token 2', + tokenId: 'token2', + scopes: ['read'], + ownerId: 'user1', + }, + ]; beforeEach(async () => { + store = jasmine.createSpyObj('Store', ['dispatch', 'selectSignal']); + confirmationService = jasmine.createSpyObj('ConfirmationService', ['confirm']); + isXSmall$ = new BehaviorSubject(false); + + store.selectSignal.and.returnValue(signal(mockTokens)); + await TestBed.configureTestingModule({ imports: [TokensListComponent], + providers: [ + { provide: Store, useValue: store }, + { provide: ConfirmationService, useValue: confirmationService }, + { provide: IS_XSMALL, useValue: isXSmall$ }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: {}, + queryParams: {}, + }, + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(TokensListComponent); @@ -19,4 +70,34 @@ describe('TokensListComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should not load tokens on init if they already exist', () => { + store.selectSignal.and.returnValue(signal(mockTokens)); + component.ngOnInit(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should show confirmation dialog when deleting token', () => { + const token = mockTokens[0]; + component.deleteToken(token); + expect(confirmationService.confirm).toHaveBeenCalled(); + }); + + it('should dispatch delete action when confirmation is accepted', () => { + const token = mockTokens[0]; + confirmationService.confirm.and.callFake((config: Confirmation) => { + if (config.accept) { + config.accept(); + } + return confirmationService; + }); + component.deleteToken(token); + expect(store.dispatch).toHaveBeenCalled(); + }); + + it('should update isXSmall signal when breakpoint changes', () => { + isXSmall$.next(true); + fixture.detectChanges(); + expect(component['isXSmall']()).toBeTrue(); + }); }); diff --git a/tsconfig.spec.json b/tsconfig.spec.json index e00e30e6d..04214d1bc 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -1,10 +1,8 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine"] + "types": ["jest", "node"] }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] }