From 6e8510ca1dc9246dc0b19f2ce39e508af3dd5ff2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 4 May 2026 20:12:39 +0100 Subject: [PATCH] [#66563] Migrate specs to Vitest Apply Angular's Vitest migration schematic to update frontend and plugin specs from Jasmine globals to Vitest APIs. ng g @schematics/angular:refactor-jasmine-vitest Fix migrated edge cases where async assertions or shallow tests changed. https://community.openproject.org/wp/66563 --- .../src/app/core/apiv3/api-v3.service.spec.ts | 14 +- .../work_packages/work-package-cache.spec.ts | 70 +++--- .../hal/resources/hal-resource.spec.ts | 77 ++++--- .../resources/work-package-resource.spec.ts | 15 +- .../app/features/plugins/hook-service.spec.ts | 24 +- .../work-package-filter-values.spec.ts | 18 +- .../keep-tab/keep-tab.service.spec.ts | 12 +- .../wp-table-pagination.component.spec.ts | 124 +++++------ .../wp-tabs/wp-tabs.component.spec.ts | 11 +- .../services/wp-tabs/wp-tabs.service.spec.ts | 25 ++- ...view-hierarchy-indentation.service.spec.ts | 63 +++--- .../attribute-help-text-modal.service.spec.ts | 78 ++++--- .../attribute-help-text.component.spec.ts | 28 ++- .../op-autocompleter/op-autocompleter.spec.ts | 90 ++++---- .../fields/edit/edit-form/edit-form.spec.ts | 31 ++- ...-edit-form-changes-tracker.service.spec.ts | 16 +- .../primer/dynamic-icon.directive.spec.ts | 28 ++- .../file-picker-base-modal.component.spec.ts | 49 ++-- .../app/shared/helpers/dom-helpers.spec.ts | 42 ++-- .../controllers/check-all.controller.spec.ts | 30 +-- .../controllers/checkable.controller.spec.ts | 38 ++-- .../generic-drag-and-drop.controller.spec.ts | 31 +-- .../controllers/truncation.controller.spec.ts | 209 ++++++++---------- frontend/src/test-setup.ts | 40 ++++ frontend/tsconfig.spec.json | 5 +- .../git-actions-menu.component.spec.ts | 51 +++-- .../git-actions/git-actions.service.spec.ts | 29 ++- .../github-tab/github-tab.component.spec.ts | 65 ++++-- .../pull-request.component.spec.ts | 31 +-- .../tab-header/tab-header.component.spec.ts | 46 ++-- .../module/tab-prs/tab-prs.component.spec.ts | 70 +++--- .../git-actions/git-actions.service.spec.ts | 29 ++- 32 files changed, 753 insertions(+), 736 deletions(-) diff --git a/frontend/src/app/core/apiv3/api-v3.service.spec.ts b/frontend/src/app/core/apiv3/api-v3.service.spec.ts index 034cfada593..06f8c6cde6a 100644 --- a/frontend/src/app/core/apiv3/api-v3.service.spec.ts +++ b/frontend/src/app/core/apiv3/api-v3.service.spec.ts @@ -26,9 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { - TestBed, -} from '@angular/core/testing'; +import { TestBed, } from '@angular/core/testing'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { States } from 'core-app/core/states/states.service'; @@ -58,7 +56,7 @@ describe('APIv3Service', () => { expect(service.projects.id(projectIdentifier).path).toEqual('/api/v3/projects/majora'); }); - it('should provide a path to work package query on subject or ID ', () => { + it('should provide a path to work package query on subject or ID', () => { let params = { filters: '[{"typeahead":{"operator":"**","values":["bogus"]}}]', sortBy: '[["updatedAt","desc"]]', @@ -66,9 +64,7 @@ describe('APIv3Service', () => { pageSize: '10', }; - expect( - service.work_packages.filterByTypeaheadOrId('bogus').path, - ).toEqual(`/api/v3/work_packages?${encodeParams(params)}`); + expect(service.work_packages.filterByTypeaheadOrId('bogus').path).toEqual(`/api/v3/work_packages?${encodeParams(params)}`); params = { filters: '[{"id":{"operator":"=","values":["1234"]}}]', @@ -77,9 +73,7 @@ describe('APIv3Service', () => { pageSize: '10', }; - expect( - service.work_packages.filterByTypeaheadOrId('1234', true).path, - ).toEqual(`/api/v3/work_packages?${encodeParams(params)}`); + expect(service.work_packages.filterByTypeaheadOrId('1234', true).path).toEqual(`/api/v3/work_packages?${encodeParams(params)}`); }); }); }); diff --git a/frontend/src/app/core/apiv3/endpoints/work_packages/work-package-cache.spec.ts b/frontend/src/app/core/apiv3/endpoints/work_packages/work-package-cache.spec.ts index 2c91997c1bf..518941237c8 100644 --- a/frontend/src/app/core/apiv3/endpoints/work_packages/work-package-cache.spec.ts +++ b/frontend/src/app/core/apiv3/endpoints/work_packages/work-package-cache.spec.ts @@ -35,7 +35,8 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { States } from 'core-app/core/states/states.service'; -import { take, takeWhile } from 'rxjs/operators'; +import { firstValueFrom } from 'rxjs'; +import { take, toArray } from 'rxjs/operators'; import { WorkPackagesActivityService } from 'core-app/features/work-packages/components/wp-single-view-tabs/activity-panel/wp-activity.service'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { WorkPackageNotificationService } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; @@ -55,8 +56,8 @@ describe('WorkPackageCache', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [OpenprojectHalModule], - providers: [ + imports: [OpenprojectHalModule], + providers: [ States, HalResourceService, TimezoneService, @@ -64,15 +65,15 @@ describe('WorkPackageCache', () => { SchemaCacheService, PathHelperService, { provide: ConfigurationService, useValue: {} }, - { provide: I18nService, useValue: { t: (...args:any[]) => 'translation' } }, + { provide: I18nService, useValue: { t: (..._args:any[]) => 'translation' } }, { provide: WorkPackageResource, useValue: {} }, { provide: ToastService, useValue: {} }, { provide: HalResourceNotificationService, useValue: { handleRawError: () => false } }, { provide: WorkPackageNotificationService, useValue: {} }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - ] -}); + ] + }); injector = TestBed.inject(Injector); states = TestBed.inject(States); @@ -80,55 +81,40 @@ describe('WorkPackageCache', () => { workPackageCache = new WorkPackageCache(injector, states.workPackages); // sinon.stub(schemaCacheService, 'ensureLoaded').returns(Promise.resolve(true)); - spyOn(schemaCacheService, 'ensureLoaded').and.resolveTo(true as any); + vi.spyOn(schemaCacheService, 'ensureLoaded').mockResolvedValue(true as any); - const workPackage1 = new WorkPackageResource( - injector, - { - id: '1', - _links: { - self: '', - }, + const workPackage1 = new WorkPackageResource(injector, { + id: '1', + _links: { + self: '', }, - true, - (wp:WorkPackageResource) => undefined, - 'WorkPackage', - ); + }, true, (_wp:WorkPackageResource) => undefined, 'WorkPackage'); dummyWorkPackages = [workPackage1 as any]; }); - it('returns a work package after the list has been initialized', (done:any) => { - workPackageCache.state('1').values$() - .pipe( - take(1), - ) - .subscribe((wp:WorkPackageResource) => { - expect(wp.id!).toEqual('1'); - done(); - }); + it('returns a work package after the list has been initialized', async () => { + const emittedWorkPackage = firstValueFrom( + workPackageCache.state('1').values$().pipe(take(1)), + ); workPackageCache.updateWorkPackageList(dummyWorkPackages); + + await expect(emittedWorkPackage).resolves.toMatchObject({ id: '1' }); }); - it('should return/stream a work package every time it gets updated', (done:any) => { - let count = 0; - - workPackageCache.state('1').values$() - .pipe( - takeWhile((wp) => count < 2), - ) - .subscribe((wp:WorkPackageResource) => { - expect(wp.id!).toEqual('1'); - - count += 1; - if (count === 2) { - done(); - } - }); + it('should return/stream a work package every time it gets updated', async () => { + const emittedWorkPackages = firstValueFrom( + workPackageCache.state('1').values$().pipe(take(3), toArray()), + ); workPackageCache.updateWorkPackageList([dummyWorkPackages[0]], false); workPackageCache.updateWorkPackageList([dummyWorkPackages[0]], false); workPackageCache.updateWorkPackageList([dummyWorkPackages[0]], false); + + const values = await emittedWorkPackages; + + expect(values).toHaveLength(3); + expect(values.map((wp) => wp.id)).toEqual(['1', '1', '1']); }); }); diff --git a/frontend/src/app/features/hal/resources/hal-resource.spec.ts b/frontend/src/app/features/hal/resources/hal-resource.spec.ts index 3001b7e3561..32e9f7e7517 100644 --- a/frontend/src/app/features/hal/resources/hal-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/hal-resource.spec.ts @@ -37,7 +37,8 @@ import { OpenprojectHalModule } from 'core-app/features/hal/openproject-hal.modu import { HalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import Spy = jasmine.Spy; +import type { Mock } from 'vitest'; +type Spy = Mock; describe('HalResource', () => { let halResourceService:HalResourceService; @@ -45,21 +46,21 @@ describe('HalResource', () => { let source:any; let resource:HalResource; - - class OtherResource extends HalResource { - } + let OtherResource:typeof HalResource; beforeEach(async () => { + OtherResource = class extends HalResource {}; + await TestBed.configureTestingModule({ - imports: [OpenprojectHalModule], - providers: [ + imports: [OpenprojectHalModule], + providers: [ HalResourceService, States, I18nService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - ] -}).compileComponents(); + ] + }).compileComponents(); halResourceService = TestBed.inject(HalResourceService); injector = TestBed.inject(Injector); }); @@ -71,7 +72,7 @@ describe('HalResource', () => { }); describe('when updating a loaded resource using `$update()`', () => { - let getStub:jasmine.Spy; + let getStub:Mock; beforeEach(() => { source = { @@ -82,7 +83,7 @@ describe('HalResource', () => { }, }; - getStub = spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => { + getStub = vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => { if (verb === 'get' && path === '/api/hello') { return of(halResourceService.createHalResource(source)) as any; } @@ -106,7 +107,7 @@ describe('HalResource', () => { }); it('should be an instance of HalResource', () => { - expect(resource).toEqual(jasmine.any(HalResource)); + expect(resource).toEqual(expect.any(HalResource)); }); }); @@ -121,19 +122,16 @@ describe('HalResource', () => { }, }; - halResourceService.registerResource( - 'Other', - { cls: OtherResource, attrTypes: { someResource: 'Other' } }, - ); + halResourceService.registerResource('Other', { cls: OtherResource, attrTypes: { someResource: 'Other' } }); resource = halResourceService.createHalResource(source, false); }); it('should be an instance of that type', () => { - expect(resource).toEqual(jasmine.any(OtherResource)); + expect(resource).toEqual(expect.any(OtherResource)); }); it('should have an attribute that is of the configured instance', () => { - expect(resource.someResource).toEqual(jasmine.any(OtherResource)); + expect(resource.someResource).toEqual(expect.any(OtherResource)); }); it('should not be loaded', () => { @@ -147,8 +145,8 @@ describe('HalResource', () => { let embeddedFn:Spy; beforeEach(() => { - linkFn = jasmine.createSpy(); - embeddedFn = jasmine.createSpy(); + linkFn = vi.fn(); + embeddedFn = vi.fn(); resource = halResourceService.createHalResource({ _links: { @@ -167,25 +165,25 @@ describe('HalResource', () => { }); it('should not have touched the source links initially', () => { - expect(linkFn.calls.count()).toEqual(0); + expect(vi.mocked(linkFn).mock.calls.length).toEqual(0); }); it('should not have touched the embedded elements of the source initially', () => { - expect(embeddedFn.calls.count()).toEqual(0); + expect(vi.mocked(embeddedFn).mock.calls.length).toEqual(0); }); it('should use the source link only once when called', () => { resource.link; resource.link; - expect(linkFn.calls.count()).toEqual(1); + expect(vi.mocked(linkFn).mock.calls.length).toEqual(1); }); it('should use the source embedded only once when called', () => { resource.resource; resource.resource; - expect(embeddedFn.calls.count()).toEqual(1); + expect(vi.mocked(embeddedFn).mock.calls.length).toEqual(1); }); }); @@ -377,7 +375,7 @@ describe('HalResource', () => { }); it('should have a callable self link', () => { - spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => { + vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => { if (verb === 'get' && path === 'unicorn/69') { return of(halResourceService.createHalResource({})) as any; } @@ -388,7 +386,7 @@ describe('HalResource', () => { }); it('should have a callable beaver', () => { - spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => { + vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => { if (verb === 'get' && path === 'justin/420') { return of(halResourceService.createHalResource({})) as any; } @@ -632,7 +630,8 @@ describe('HalResource', () => { it('should not be possible to override a link', () => { try { resource.$links.action = 'foo'; - } catch (ignore) { + } + catch (ignore) { /**/ } @@ -642,7 +641,8 @@ describe('HalResource', () => { it('should not be possible to override an embedded resource', () => { try { resource.$embedded.embedded = 'foo'; - } catch (ignore) { + } + catch (ignore) { /**/ } @@ -713,18 +713,18 @@ describe('HalResource', () => { }); describe('when loading it', () => { - let getStub:jasmine.Spy; + let getStub:Mock; let newResult:any; let promise:Promise; - beforeEach((done) => { + beforeEach(async () => { const result = halResourceService.createHalResource({ _links: {}, name: 'name', foo: 'bar', }); - getStub = spyOn(halResourceService, 'request').and.callFake((verb:string, path:string) => { + getStub = vi.spyOn(halResourceService, 'request').mockImplementation((verb:string, path:string) => { if (verb === 'get' && path === '/api/property') { return of(result) as any; } @@ -737,14 +737,13 @@ describe('HalResource', () => { }); expect(getStub).toHaveBeenCalled(); - done(); + ; }); - it('should be loaded', (done) => { - promise.then(() => { - expect(resource.$loaded).toBeTruthy(); - done(); - }).catch(done.fail); + it('should be loaded', async () => { + await promise; + + expect(resource.$loaded).toBeTruthy(); }); it('should be updated', () => { @@ -754,10 +753,10 @@ describe('HalResource', () => { it('should have properties that have a getter and setter', () => { const descriptor = Object.getOwnPropertyDescriptor(newResult, 'foo'); - expect(descriptor).toBeDefined('Descriptor should be defined'); + expect(descriptor).toBeDefined(); - expect(descriptor!.get).toBeDefined('Descriptor getter should be defined'); - expect(descriptor!.set).toBeDefined('Descriptor setter should be defined'); + expect(descriptor!.get).toBeDefined(); + expect(descriptor!.set).toBeDefined(); }); it('should return itself in a promise if already loaded', () => { diff --git a/frontend/src/app/features/hal/resources/work-package-resource.spec.ts b/frontend/src/app/features/hal/resources/work-package-resource.spec.ts index 38bd71dfbef..bdc805a2b08 100644 --- a/frontend/src/app/features/hal/resources/work-package-resource.spec.ts +++ b/frontend/src/app/features/hal/resources/work-package-resource.spec.ts @@ -70,8 +70,8 @@ describe('WorkPackage', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [OpenprojectHalModule], - providers: [ + imports: [OpenprojectHalModule], + providers: [ HalResourceService, States, TimezoneService, @@ -89,8 +89,8 @@ describe('WorkPackage', () => { { provide: SchemaCacheService, useValue: {} }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting(), - ] -}).compileComponents(); + ] + }).compileComponents(); halResourceService = TestBed.inject(HalResourceService); injector = TestBed.inject(Injector); halResourceNotification = injector.get(HalResourceNotificationService); @@ -109,7 +109,7 @@ describe('WorkPackage', () => { beforeEach(createWorkPackage); it('should have an attachments property of type `AttachmentCollectionResource`', () => { - expect(workPackage.attachments).toEqual(jasmine.any(AttachmentCollectionResource)); + expect(workPackage.attachments).toEqual(expect.any(AttachmentCollectionResource)); }); it('should return true for `isNewResource`', () => { @@ -190,11 +190,12 @@ describe('WorkPackage', () => { it('surfaces the semantic displayId on each ancestor resource', () => { const ancestor = (workPackage as any).ancestors[0] as WorkPackageResource; + expect(ancestor.displayId).toEqual('ACSMT-15'); }); }); -}); + }); describe('formattedId', () => { afterEach(() => { @@ -215,7 +216,7 @@ describe('WorkPackage', () => { expect(workPackage.formattedId).toEqual('#42'); }); -}); + }); describe('subjectWithId', () => { afterEach(() => { diff --git a/frontend/src/app/features/plugins/hook-service.spec.ts b/frontend/src/app/features/plugins/hook-service.spec.ts index e87f3f70481..b57c8d2554d 100644 --- a/frontend/src/app/features/plugins/hook-service.spec.ts +++ b/frontend/src/app/features/plugins/hook-service.spec.ts @@ -31,8 +31,8 @@ import { HookService } from 'core-app/features/plugins/hook-service'; describe('HookService', () => { let service:HookService = new HookService(); - let callback:any; let - invalidCallback:any; + let callback:any; + let invalidCallback:any; const validId = 'myValidCallbacks'; beforeEach(() => { @@ -80,7 +80,7 @@ describe('HookService', () => { describe('valid function callback registered', () => { beforeEach(() => { - callback = jasmine.createSpy('hook'); + callback = vi.fn(); service.register('myValidCallbacks', callback); }); @@ -93,7 +93,7 @@ describe('HookService', () => { describe('call', () => { describe('function that returns undefined', () => { beforeEach(() => { - callback = jasmine.createSpy('hook'); + callback = vi.fn(); service.register('myValidCallbacks', callback); }); @@ -104,7 +104,7 @@ describe('HookService', () => { describe('function that returns something that is not undefined', () => { beforeEach(() => { - callback = jasmine.createSpy('hook').and.returnValue({}); + callback = vi.fn().mockReturnValue({}); service.register('myValidCallbacks', callback); }); @@ -116,7 +116,7 @@ describe('HookService', () => { describe('function that returns something that is not undefined', () => { beforeEach(() => { - callback = jasmine.createSpy('hook').and.returnValue({}); + callback = vi.fn().mockReturnValue({}); service.register('myValidCallbacks', callback); }); @@ -128,8 +128,8 @@ describe('HookService', () => { describe('function that returns something that is not undefined', () => { beforeEach(() => { - callback = jasmine.createSpy('hook'); - invalidCallback = jasmine.createSpy('invalidHook'); + callback = vi.fn(); + invalidCallback = vi.fn(); service.register('myValidCallbacks', callback); @@ -142,12 +142,12 @@ describe('HookService', () => { }); describe('function that returns something that is not undefined', () => { - let callback1; let - callback2; + let callback1; + let callback2; beforeEach(() => { - callback1 = jasmine.createSpy('hook1').and.returnValue({}); - callback2 = jasmine.createSpy('hook1').and.returnValue({}); + callback1 = vi.fn().mockReturnValue({}); + callback2 = vi.fn().mockReturnValue({}); service.register('myValidCallbacks', callback1); service.register('myValidCallbacks', callback2); diff --git a/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts b/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts index e64f5f951c0..db6dc6b67e3 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit-form/work-package-filter-values.spec.ts @@ -73,8 +73,8 @@ describe('WorkPackageFilterValues', () => { function setupTestBed() { // noinspection JSIgnoredPromiseFromCall void TestBed.configureTestingModule({ - imports: [UIRouterModule.forRoot({})], - providers: [ + imports: [UIRouterModule.forRoot({})], + providers: [ I18nService, { provide: WeekdayService, useValue: WeekdayServiceStub }, States, @@ -95,8 +95,8 @@ describe('WorkPackageFilterValues', () => { HalResourceEditingService, WorkPackagesActivityService, provideHttpClient(withInterceptorsFromDi()), - ] -}).compileComponents(); + ] + }).compileComponents(); injector = TestBed.inject(Injector); halResourceService = injector.get(HalResourceService); @@ -104,14 +104,8 @@ describe('WorkPackageFilterValues', () => { resource = halResourceService.createHalResourceOfClass(WorkPackageResource, source, true); changeset = new WorkPackageChangeset(resource); - const type1 = halResourceService.createHalResourceOfClass( - TypeResource, - { _type: 'Type', id: '1', _links: { self: { href: '/api/v3/types/1', name: 'Task' } } }, - ); - const type2 = halResourceService.createHalResourceOfClass( - TypeResource, - { _type: 'Type', id: '2', _links: { self: { href: '/api/v3/types/2', name: 'Bug' } } }, - ); + const type1 = halResourceService.createHalResourceOfClass(TypeResource, { _type: 'Type', id: '1', _links: { self: { href: '/api/v3/types/1', name: 'Task' } } }); + const type2 = halResourceService.createHalResourceOfClass(TypeResource, { _type: 'Type', id: '2', _links: { self: { href: '/api/v3/types/2', name: 'Bug' } } }); filters = [ { diff --git a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts index 9e9302a92b4..4344381b8d4 100644 --- a/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-single-view-tabs/keep-tab/keep-tab.service.spec.ts @@ -79,7 +79,7 @@ describe('keepTab service', () => { let currentPathPrefix = 'work-packages.show.*'; beforeEach(() => { - spyOn($state, 'includes').and.callFake((path:string) => path === currentPathPrefix); + vi.spyOn($state, 'includes').mockImplementation((path:string) => path === currentPathPrefix); $state.current.name = 'work-packages.show.tabs'; uiRouterGlobals.params.tabIdentifier = 'relations'; @@ -95,7 +95,7 @@ describe('keepTab service', () => { }); it('should propagate the previous change', () => { - const cb = jasmine.createSpy(); + const cb = vi.fn(); const expected = { active: 'relations', @@ -121,7 +121,7 @@ describe('keepTab service', () => { describe('when opening show#activity', () => { beforeEach(() => { - spyOn($state, 'includes').and.callFake((path:string) => path === 'work-packages.show.*'); + vi.spyOn($state, 'includes').mockImplementation((path:string) => path === 'work-packages.show.*'); uiRouterGlobals.params.tabIdentifier = 'activity'; $state.current.name = 'work-packages.show.tabs'; @@ -135,7 +135,7 @@ describe('keepTab service', () => { describe('when opening a details route', () => { beforeEach(() => { - spyOn($state, 'includes').and.callFake((path:string) => path === '**.details.*'); + vi.spyOn($state, 'includes').mockImplementation((path:string) => path === '**.details.*'); uiRouterGlobals.params.tabIdentifier = 'activity'; $state.current.name = 'work-packages.partitioned.list.details.tabs'; @@ -151,7 +151,7 @@ describe('keepTab service', () => { }); it('should propagate the previous and next change', () => { - const cb = jasmine.createSpy(); + const cb = vi.fn(); const expected = { active: 'activity', @@ -165,7 +165,7 @@ describe('keepTab service', () => { keepTab.updateTabs(); - expect(cb.calls.count()).toEqual(2); + expect(vi.mocked(cb).mock.calls.length).toEqual(2); }); }); }); diff --git a/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts b/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts index 56bf3d9deee..7d582079699 100644 --- a/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-table/table-pagination/wp-table-pagination.component.spec.ts @@ -52,14 +52,11 @@ function setupMocks(paginationService:PaginationService) { optionsTruncationSize: 6, }; - // eslint-disable-next-line jasmine/no-unsafe-spy - spyOn(paginationService, 'getMaxVisiblePageOptions').and.callFake(() => options.maxVisiblePageOptions); + vi.spyOn(paginationService, 'getMaxVisiblePageOptions').mockImplementation(() => options.maxVisiblePageOptions); - // eslint-disable-next-line jasmine/no-unsafe-spy - spyOn(paginationService, 'getOptionsTruncationSize').and.callFake(() => options.optionsTruncationSize); + vi.spyOn(paginationService, 'getOptionsTruncationSize').mockImplementation(() => options.optionsTruncationSize); - // eslint-disable-next-line jasmine/no-unsafe-spy - spyOn(paginationService, 'getPaginationOptions').and.callFake(() => options); + vi.spyOn(paginationService, 'getPaginationOptions').mockImplementation(() => options); } function pageString(element:HTMLElement) { @@ -75,12 +72,12 @@ describe('wpTablePagination Directive', () => { }; await TestBed.configureTestingModule({ - declarations: [ + declarations: [ WorkPackageTablePaginationComponent, OpIconComponent, - ], - imports: [], - providers: [ + ], + imports: [], + providers: [ States, PaginationService, WorkPackageViewSortByService, @@ -92,80 +89,77 @@ describe('wpTablePagination Directive', () => { IsolatedQuerySpace, I18nService, provideHttpClient(withInterceptorsFromDi()), - ] -}).compileComponents(); + ] + }).compileComponents(); }); describe('page ranges and links', () => { - it('should display the correct page range', - inject([PaginationService], (paginationService:PaginationService) => { - setupMocks(paginationService); - const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); - const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; - const element = fixture.elementRef.nativeElement; + it('should display the correct page range', inject([PaginationService], (paginationService:PaginationService) => { + setupMocks(paginationService); + const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); + const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; + const element = fixture.elementRef.nativeElement; - app.pagination = new PaginationInstance(1, 0, 10); - app.update(); - fixture.detectChanges(); + app.pagination = new PaginationInstance(1, 0, 10); + app.update(); + fixture.detectChanges(); - expect(pageString(element)).toEqual(''); + expect(pageString(element)).toEqual(''); - app.pagination = new PaginationInstance(1, 11, 10); - app.update(); - fixture.detectChanges(); + app.pagination = new PaginationInstance(1, 11, 10); + app.update(); + fixture.detectChanges(); - expect(pageString(element)).toEqual('(1 - 10/11)'); - })); + expect(pageString(element)).toEqual('(1 - 10/11)'); + })); describe('"next" link', () => { - it('hidden on the last page', - inject([PaginationService], (paginationService:PaginationService) => { - setupMocks(paginationService); - const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); - const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; - const element = fixture.elementRef.nativeElement; - - app.pagination = new PaginationInstance(2, 11, 10); - app.update(); - fixture.detectChanges(); - - const liWithNextLink = element.querySelector('.op-pagination--item-link_next')?.parentElement; - - expect(liWithNextLink?.matches('li')).toBeTrue(); - const attrHidden = liWithNextLink.getAttribute('hidden'); - - expect(attrHidden).toBeDefined(); - })); - }); - - it('should display correct number of page number links', - inject([PaginationService], (paginationService:PaginationService) => { + it('hidden on the last page', inject([PaginationService], (paginationService:PaginationService) => { setupMocks(paginationService); const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; const element = fixture.elementRef.nativeElement; - function numberOfPageNumberLinks() { - return element.querySelectorAll('button[data-rel="next"]').length; - } - - app.pagination = new PaginationInstance(1, 1, 10); + app.pagination = new PaginationInstance(2, 11, 10); app.update(); fixture.detectChanges(); - expect(numberOfPageNumberLinks()).toEqual(1); + const liWithNextLink = element.querySelector('.op-pagination--item-link_next')?.parentElement; - app.pagination = new PaginationInstance(1, 11, 10); - app.update(); - fixture.detectChanges(); + expect(liWithNextLink?.matches('li')).toBe(true); + const attrHidden = liWithNextLink.getAttribute('hidden'); - expect(numberOfPageNumberLinks()).toEqual(2); - - app.pagination = new PaginationInstance(1, 59, 10); - app.update(); - fixture.detectChanges(); - - expect(numberOfPageNumberLinks()).toEqual(6); + expect(attrHidden).toBeDefined(); })); + }); + + it('should display correct number of page number links', inject([PaginationService], (paginationService:PaginationService) => { + setupMocks(paginationService); + const fixture = TestBed.createComponent(WorkPackageTablePaginationComponent); + const app:WorkPackageTablePaginationComponent = fixture.debugElement.componentInstance; + const element = fixture.elementRef.nativeElement; + + function numberOfPageNumberLinks() { + return element.querySelectorAll('button[data-rel="next"]').length; + } + + app.pagination = new PaginationInstance(1, 1, 10); + app.update(); + fixture.detectChanges(); + + expect(numberOfPageNumberLinks()).toEqual(1); + + app.pagination = new PaginationInstance(1, 11, 10); + app.update(); + fixture.detectChanges(); + + expect(numberOfPageNumberLinks()).toEqual(2); + + app.pagination = new PaginationInstance(1, 59, 10); + app.update(); + fixture.detectChanges(); + + expect(numberOfPageNumberLinks()).toEqual(6); + })); }); }); diff --git a/frontend/src/app/features/work-packages/components/wp-tabs/components/wp-tabs/wp-tabs.component.spec.ts b/frontend/src/app/features/work-packages/components/wp-tabs/components/wp-tabs/wp-tabs.component.spec.ts index 82ee26fa3c0..db1ac5ac5cf 100644 --- a/frontend/src/app/features/work-packages/components/wp-tabs/components/wp-tabs/wp-tabs.component.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-tabs/components/wp-tabs/wp-tabs.component.spec.ts @@ -1,4 +1,4 @@ -import { Input, NO_ERRORS_SCHEMA } from '@angular/core'; +import { Component, Input, NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; @@ -12,8 +12,13 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; describe('WpTabsComponent', () => { + @Component({ + template: '', + standalone: false, + }) class TestComponent implements TabComponent { - @Input() public workPackage:WorkPackageResource; + @Input() + public workPackage:WorkPackageResource; } const displayableTab = { @@ -64,6 +69,6 @@ describe('WpTabsComponent', () => { it('displays the visible tab', () => { const tabLink:HTMLElement = fixture.debugElement.query(By.css('[data-qa-tab-id="displayable-test-tab"]')).nativeElement; - expect(tabLink.innerText).toContain('Displayable TestTab'); + expect(tabLink.textContent).toContain('Displayable TestTab'); }); }); diff --git a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts index c94f83e122b..a493f587291 100644 --- a/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts +++ b/frontend/src/app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service.spec.ts @@ -1,20 +1,23 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { Input } from '@angular/core'; -import { StateService } from '@uirouter/angular'; +import { Component, Input } from '@angular/core'; +import { StateService } from '@uirouter/core'; import { TestBed } from '@angular/core/testing'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; -import { - WorkPackageTabsService, -} from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service'; +import { WorkPackageTabsService, } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service'; import { TabComponent } from '../../components/wp-tab-wrapper/tab'; describe('WpTabsService', () => { let service:WorkPackageTabsService; const workPackage:any = { id: 1234 }; + @Component({ + template: '', + standalone: false, + }) class TestComponent implements TabComponent { - @Input() public workPackage:WorkPackageResource; + @Input() + public workPackage:WorkPackageResource; } const displayableTab = { @@ -34,12 +37,12 @@ describe('WpTabsService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [], - providers: [ + imports: [], + providers: [ { provide: StateService, useValue: { includes: () => false } }, provideHttpClient(withInterceptorsFromDi()), - ] -}); + ] + }); service = TestBed.inject(WorkPackageTabsService); (service as any).registeredTabs = []; service.register({ ...displayableTab }, { ...notDisplayableTab }); @@ -66,7 +69,7 @@ describe('WpTabsService', () => { const displayableTabs = service.getDisplayableTabs(workPackage); - expect(displayableTabs).toHaveSize(1); + expect(displayableTabs).toHaveLength(1); expect(displayableTabs[0].id).toEqual(notDisplayableTab.id); }); }); diff --git a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy-indentation.service.spec.ts b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy-indentation.service.spec.ts index ad3b882cff1..b504dfff24f 100644 --- a/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy-indentation.service.spec.ts +++ b/frontend/src/app/features/work-packages/routing/wp-view-base/view-services/wp-view-hierarchy-indentation.service.spec.ts @@ -35,7 +35,8 @@ import { WorkPackageViewHierarchyIdentationService } from 'core-app/features/wor import { WorkPackageViewDisplayRepresentationService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-display-representation.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { of } from 'rxjs'; -import SpyObj = jasmine.SpyObj; +import type { MockedObject } from 'vitest'; +type SpyObj = MockedObject; describe('WorkPackageViewIndentation service', () => { let service:WorkPackageViewHierarchyIdentationService; @@ -59,12 +60,11 @@ describe('WorkPackageViewIndentation service', () => { } beforeEach(async () => { - parentServiceSpy = jasmine.createSpyObj( - 'WorkPackageRelationHierarchyService', - ['changeParent'], - ); + parentServiceSpy = { + changeParent: vi.fn().mockName('WorkPackageRelationHierarchyService.changeParent') + }; - parentServiceSpy.changeParent.and.resolveTo(); + parentServiceSpy.changeParent.mockResolvedValue(); await TestBed.configureTestingModule({ providers: [ @@ -118,8 +118,7 @@ describe('WorkPackageViewIndentation service', () => { { workPackageId: '1234', hidden: false, classIdentifier: 'foo' }, ]); - spyOnProperty(hierarchyServiceStub, 'isEnabled', 'get') - .and.returnValue(false); + vi.spyOn(hierarchyServiceStub, 'isEnabled', 'get').mockReturnValue(false); const workPackage:any = { id: '1234', changeParent: () => 'foo', ancestorIds: [] }; @@ -160,8 +159,7 @@ describe('WorkPackageViewIndentation service', () => { it('Cannot outdent with changeParent link but disabled', () => { const workPackage:any = { id: '1234', changeParent: () => 'foo', parent: { id: '2345' } }; - spyOnProperty(hierarchyServiceStub, 'isEnabled', 'get') - .and.returnValue(false); + vi.spyOn(hierarchyServiceStub, 'isEnabled', 'get').mockReturnValue(false); expect(service.canOutdent(workPackage)).toBeFalsy(); }); @@ -174,7 +172,7 @@ describe('WorkPackageViewIndentation service', () => { }); describe('indent', () => { - it('Can indent with a predecessor that is NOT an ancestor already', (done) => { + it('Can indent with a predecessor that is NOT an ancestor already', async () => { querySpace.tableRendered.putValue([ { workPackageId: '5555', hidden: false, classIdentifier: 'foo' }, { workPackageId: '2345', hidden: false, classIdentifier: 'foo' }, @@ -186,13 +184,12 @@ describe('WorkPackageViewIndentation service', () => { states.workPackages.get('2345').putValue(predecessor); - service.indent(workPackage).then(() => { - expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345'); - done(); - }).catch(done.fail); + await service.indent(workPackage); + + expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345'); }); - it('Can indent with a predecessor that shares an ancestor chain', (done) => { + it('Can indent with a predecessor that shares an ancestor chain', async () => { querySpace.tableRendered.putValue([ { workPackageId: '5555', hidden: false, classIdentifier: 'foo' }, { workPackageId: '2345', hidden: false, classIdentifier: 'foo' }, @@ -204,13 +201,12 @@ describe('WorkPackageViewIndentation service', () => { states.workPackages.get('2345').putValue(predecessor); - service.indent(workPackage).then(() => { - expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '5555'); - done(); - }).catch(done.fail); + await service.indent(workPackage); + + expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '5555'); }); - it('Can indent with a predecessor that shares an ancestor chain', (done) => { + it('Can indent with a predecessor that shares an ancestor chain', async () => { querySpace.tableRendered.putValue([ { workPackageId: '5555', hidden: false, classIdentifier: 'foo' }, { workPackageId: '2345', hidden: false, classIdentifier: 'foo' }, @@ -222,15 +218,14 @@ describe('WorkPackageViewIndentation service', () => { states.workPackages.get('2345').putValue(predecessor); - service.indent(workPackage).then(() => { - expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345'); - done(); - }).catch(done.fail); + await service.indent(workPackage); + + expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345'); }); }); describe('outdent', () => { - it('will outdent to the previous last ancestorId', (done) => { + it('will outdent to the previous last ancestorId', async () => { querySpace.tableRendered.putValue([ { workPackageId: '1234', hidden: false, classIdentifier: 'foo' }, ]); @@ -239,13 +234,12 @@ describe('WorkPackageViewIndentation service', () => { id: '1234', changeParent: () => 'foo', parent: '5555', ancestorIds: ['2345', '5555'], }; - service.outdent(workPackage).then(() => { - expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345'); - done(); - }).catch(done.fail); + await service.outdent(workPackage); + + expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, '2345'); }); - it('will outdent to null in case of ancestorIds.length < 2', (done) => { + it('will outdent to null in case of ancestorIds.length < 2', async () => { querySpace.tableRendered.putValue([ { workPackageId: '1234', hidden: false, classIdentifier: 'foo' }, ]); @@ -254,10 +248,9 @@ describe('WorkPackageViewIndentation service', () => { id: '1234', changeParent: () => 'foo', parent: '2345', ancestorIds: ['2345'], }; - service.outdent(workPackage).then(() => { - expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, null); - done(); - }).catch(done.fail); + await service.outdent(workPackage); + + expect(parentServiceSpy.changeParent).toHaveBeenCalledWith(workPackage, null); }); }); }); diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.spec.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.spec.ts index 845711ae1e5..8d7c28da137 100644 --- a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.spec.ts +++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.spec.ts @@ -1,3 +1,4 @@ +import type { Mock } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { AttributeHelpTextsService } from './attribute-help-text.service'; import { AttributeHelpTextModalService } from './attribute-help-text-modal.service'; @@ -6,12 +7,12 @@ import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service import { ToastService } from '../toaster/toast.service'; describe('AttributeHelpTextModalService', () => { - let fetchSpy:jasmine.Spy; + let fetchSpy:Mock; let modalService:AttributeHelpTextModalService; let dialog:HTMLDialogElement|null; beforeEach(() => { - fetchSpy = spyOn(window, 'fetch'); + fetchSpy = vi.spyOn(window, 'fetch') as unknown as Mock; }); beforeEach(async () => { @@ -34,12 +35,12 @@ describe('AttributeHelpTextModalService', () => { const makeSuccessResponse = (dialogId:string, dialogContent:string) => { const body = ` - - `; + + `; return new Response(body, { status: 200, headers: { 'Content-Type': 'text/vnd.turbo-stream.html' } }); }; @@ -47,84 +48,84 @@ describe('AttributeHelpTextModalService', () => { afterEach(() => { dialog?.remove(); dialog = null; + vi.restoreAllMocks(); }); describe('with a successful request', () => { beforeEach(() => { - fetchSpy - .withArgs(jasmine.stringMatching('/1/show_dialog'), jasmine.any(Object)) - .and.resolveTo(makeSuccessResponse('test1', 'Hello Dialog')); + fetchSpy.mockImplementation((url:RequestInfo | URL) => { + const requestUrl = url instanceof Request ? url.url : url.toString(); + + if (requestUrl.includes('/1/show_dialog')) { + return Promise.resolve(makeSuccessResponse('test1', 'Hello Dialog')); + } + return Promise.reject(new Error(`Unexpected url: ${requestUrl}`)); + }); }); it('should handle Turbo Stream dialog response and open the dialog', async () => { expect(document.querySelector('dialog#test1')).toBeFalsy(); - await expectAsync(modalService.show('1')).toBeResolved(); + await modalService.show('1'); expect(fetchSpy).toHaveBeenCalledTimes(1); dialog = await waitForNativeElement('dialog#test1'); expect(dialog.textContent).toEqual('Hello Dialog'); - expect(dialog.open).toBeTrue(); + expect(dialog.open).toBe(true); dialog.close(); - expect(dialog.open).toBeFalse(); + expect(dialog.open).toBe(false); }); }); describe('with an aborted request followed by a successful request', () => { beforeEach(() => { fetchSpy - .withArgs(jasmine.stringMatching('/2/show_dialog'), jasmine.any(Object)) - .and.returnValues( - Promise.reject(new DOMException('message', 'AbortError')), - Promise.resolve(makeSuccessResponse('test2', 'Noch mal ein Dialog')), - ); + .mockReturnValueOnce(Promise.reject(new DOMException('message', 'AbortError'))) + .mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test2', 'Noch mal ein Dialog'))); }); it('should handle Turbo Stream dialog response and still open the dialog', async () => { expect(document.querySelector('dialog#test2')).toBeFalsy(); - await expectAsync(modalService.show('2')).toBeRejected(); - await expectAsync(modalService.show('2')).toBeResolved(); + await expect(modalService.show('2')).rejects.toThrow(); + await modalService.show('2'); expect(fetchSpy).toHaveBeenCalledTimes(2); dialog = await waitForNativeElement('dialog#test2'); expect(dialog.textContent).toEqual('Noch mal ein Dialog'); - expect(dialog.open).toBeTrue(); + expect(dialog.open).toBe(true); dialog.close(); - expect(dialog.open).toBeFalse(); + expect(dialog.open).toBe(false); }); }); describe('with 3 successful requests with the same dialog id', () => { beforeEach(() => { fetchSpy - .withArgs(jasmine.stringMatching('/3/show_dialog'), jasmine.any(Object)) - .and.returnValues( - Promise.resolve(makeSuccessResponse('test3', '

initial content

')), - Promise.resolve(makeSuccessResponse('test3', '

updated content

')), - Promise.resolve(makeSuccessResponse('test3', '

new headline

')), - ); + .mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test3', '

initial content

'))) + .mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test3', '

updated content

'))) + .mockReturnValueOnce(Promise.resolve(makeSuccessResponse('test3', '

new headline

'))); }); it('should handle Turbo Stream dialog response and update dialog', async () => { expect(document.querySelector('dialog#test3')).toBeFalsy(); - await expectAsync(modalService.show('3')).toBeResolved(); + await modalService.show('3'); expect(fetchSpy).toHaveBeenCalledTimes(1); dialog = await waitForNativeElement('dialog#test3'); expect(dialog.textContent).toEqual('initial content'); - expect(dialog.open).toBeTrue(); + expect(dialog.open).toBe(true); - await expectAsync(modalService.show('3')).toBeResolved(); + await modalService.show('3'); expect(fetchSpy).toHaveBeenCalledTimes(2); @@ -132,9 +133,9 @@ describe('AttributeHelpTextModalService', () => { expect(mutation.type).toEqual('characterData'); expect(dialog.textContent).toEqual('updated content'); - expect(dialog.open).toBeTrue(); + expect(dialog.open).toBe(true); - await expectAsync(modalService.show('3')).toBeResolved(); + await modalService.show('3'); expect(fetchSpy).toHaveBeenCalledTimes(3); @@ -143,11 +144,11 @@ describe('AttributeHelpTextModalService', () => { expect(mutation.type).toEqual('childList'); expect(dialog.textContent).toEqual('new headline'); expect(dialog.querySelector('h3')).toBeTruthy(); - expect(dialog.open).toBeTrue(); + expect(dialog.open).toBe(true); dialog.close(); - expect(dialog.open).toBeFalse(); + expect(dialog.open).toBe(false); }); }); }); @@ -170,10 +171,7 @@ function waitForNativeElement(selector:string):Promise { }); } -function waitForElementMutation( - element:T, - predicate:(mutation:MutationRecord) => boolean = () => true, -):Promise { +function waitForElementMutation(element:T, predicate:(mutation:MutationRecord) => boolean = () => true):Promise { return new Promise((resolve) => { const observer = new MutationObserver((mutationList) => { const record = mutationList.find(predicate); diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts index 3eef69de171..ec2f206789d 100644 --- a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts +++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts @@ -14,12 +14,14 @@ describe('AttributeHelpTextComponent', () => { let element:DebugElement; const serviceStub = {}; - let modalServiceStub:jasmine.SpyObj; - const i18nStub = { t: (_scope:string|string[], _options?:Record) => 'Show help text' }; + let modalServiceStub:{ show:ReturnType }; + const i18nStub = { t: (_scope:string | string[], _options?:Record) => 'Show help text' }; beforeEach(async () => { - modalServiceStub = jasmine.createSpyObj('AttributeHelpTextModalService', ['show']); - modalServiceStub.show.and.resolveTo(); + modalServiceStub = { + show: vi.fn().mockName('AttributeHelpTextModalService.show') + }; + modalServiceStub.show.mockResolvedValue(undefined); await TestBed .configureTestingModule({ @@ -59,7 +61,7 @@ describe('AttributeHelpTextComponent', () => { const button = element.query(By.css("[role='button']")); expect(button).toBeTruthy(); - expect(button.nativeElement).toHaveClass('spot-link'); + expect(button.nativeElement.classList.contains('spot-link')).toBe(true); }); it('renders a tooltip', () => { @@ -68,7 +70,7 @@ describe('AttributeHelpTextComponent', () => { expect(tooltip).toBeTruthy(); expect(tooltip.nativeElement.textContent).toEqual('Show help text'); expect(tooltip.nativeElement.getAttribute('for')).toMatch(/attribute-help-text-component-\d+/); - expect(tooltip.nativeElement.popover).toEqual('manual'); + expect(tooltip.nativeElement.getAttribute('popover')).toEqual('manual'); expect(tooltip.nativeElement.dataset.direction).toEqual('sw'); expect(tooltip.nativeElement.dataset.type).toEqual('label'); }); @@ -82,7 +84,7 @@ describe('AttributeHelpTextComponent', () => { it('applies .help-text--entry class', () => { const button = element.query(By.css("[role='button']")); - expect(button.nativeElement).toHaveClass('help-text--entry'); + expect(button.nativeElement.classList.contains('help-text--entry')).toBe(true); }); it('applies an ID', () => { @@ -106,11 +108,13 @@ describe('AttributeHelpTextComponent', () => { expect(button.nativeElement.ariaDisabled).toEqual('true'); await Promise.resolve(); - await modalServiceStub.show.calls.mostRecent().returnValue; + await modalServiceStub.show.mock.results.at(-1)!.value; await new Promise(resolve => setTimeout(resolve, 0)); fixture.detectChanges(); - expect(modalServiceStub.show).toHaveBeenCalledOnceWith('1'); + expect(modalServiceStub.show).toHaveBeenCalledTimes(1); + + expect(modalServiceStub.show).toHaveBeenCalledWith('1'); expect(button.nativeElement.ariaDisabled).toEqual('false'); }); @@ -128,11 +132,13 @@ describe('AttributeHelpTextComponent', () => { fixture.detectChanges(); await Promise.resolve(); - await modalServiceStub.show.calls.mostRecent().returnValue; + await modalServiceStub.show.mock.results.at(-1)!.value; await new Promise(resolve => setTimeout(resolve, 0)); fixture.detectChanges(); - expect(modalServiceStub.show).toHaveBeenCalledOnceWith('1'); + expect(modalServiceStub.show).toHaveBeenCalledTimes(1); + + expect(modalServiceStub.show).toHaveBeenCalledWith('1'); expect(button.nativeElement.ariaDisabled).toEqual('false'); }); }); diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts index 91626d3843b..5b2f00691c4 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.spec.ts @@ -1,3 +1,4 @@ +import type { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { States } from 'core-app/core/states/states.service'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -12,7 +13,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' describe('autocompleter', () => { let fixture:ComponentFixture; - let getOptionsFnSpy:jasmine.Spy; + let getOptionsFnSpy:Mock; const workPackagesStub = [ { id: 1, @@ -60,7 +61,11 @@ describe('autocompleter', () => { }, ]; - type WindowWithOpenProject = Omit & { OpenProject?:{ environment:string } }; + type WindowWithOpenProject = Omit & { + OpenProject?:{ + environment:string; + }; + }; beforeEach(() => { (window as WindowWithOpenProject).OpenProject = { environment: 'test' }; @@ -79,10 +84,8 @@ describe('autocompleter', () => { }).compileComponents(); fixture = TestBed.createComponent(OpAutocompleterComponent); - getOptionsFnSpy = jasmine.createSpy('getOptionsFn').and.callFake((searchTerm:string) => { - return of(workPackagesStub).pipe( - map((wps) => wps.filter((wp) => searchTerm !== '' && wp.subject.includes(searchTerm))) - ); + getOptionsFnSpy = vi.fn().mockImplementation((searchTerm:string) => { + return of(workPackagesStub).pipe(map((wps) => wps.filter((wp) => searchTerm !== '' && wp.subject.includes(searchTerm)))); }); fixture.componentInstance.resource = 'work_packages' as TOpAutocompleterResource; @@ -98,35 +101,36 @@ describe('autocompleter', () => { }); it('should load the ng-select correctly', () => { - jasmine.clock().install(); + vi.useFakeTimers(); try { fixture.detectChanges(); - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); const autocompleter = document.querySelector('.ng-select-container'); expect(document.contains(autocompleter)).toBeTruthy(); - } finally { - jasmine.clock().uninstall(); + } + finally { + vi.useRealTimers(); } }); describe('without debounce', () => { it('should load items', () => { - jasmine.clock().install(); + vi.useFakeTimers(); try { - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); fixture.detectChanges(); fixture.componentInstance.ngAfterViewInit(); - jasmine.clock().tick(1000); + vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; - expect(select.isOpen()).toBeFalse(); + expect(select.isOpen()).toBe(false); select.open(); select.focus(); - expect(select.isOpen()).toBeTrue(); + expect(select.isOpen()).toBe(true); expect(select.itemsList.items.length).toEqual(0); @@ -134,14 +138,14 @@ describe('autocompleter', () => { const inputElement = inputDebugElement.nativeElement as HTMLInputElement; fixture.detectChanges(); - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); expect(getOptionsFnSpy).toHaveBeenCalledWith(''); inputElement.value = 'Wor'; inputElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); expect(getOptionsFnSpy).toHaveBeenCalledWith('Wor'); @@ -152,27 +156,28 @@ describe('autocompleter', () => { inputElement.value = 'package 2'; inputElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); expect(getOptionsFnSpy).toHaveBeenCalledWith('package 2'); fixture.detectChanges(); expect(select.itemsList.items.length).toEqual(1); - } finally { - jasmine.clock().uninstall(); + } + finally { + vi.useRealTimers(); } }); }); describe('work package option rendering', () => { it('should display formattedId in dropdown options', () => { - jasmine.clock().install(); + vi.useFakeTimers(); try { - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); fixture.detectChanges(); fixture.componentInstance.ngAfterViewInit(); - jasmine.clock().tick(1000); + vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; @@ -185,7 +190,7 @@ describe('autocompleter', () => { inputElement.value = 'Wor'; inputElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); fixture.detectChanges(); const wpIdElements = document.querySelectorAll('.op-autocompleter--wp-id'); @@ -195,18 +200,19 @@ describe('autocompleter', () => { const renderedIds = Array.from(wpIdElements).map(el => el.textContent?.trim()); expect(renderedIds).toContain('#1'); - } finally { - jasmine.clock().uninstall(); + } + finally { + vi.useRealTimers(); } }); it('should display classic formattedId in selected value label', () => { - jasmine.clock().install(); + vi.useFakeTimers(); try { - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); fixture.detectChanges(); fixture.componentInstance.ngAfterViewInit(); - jasmine.clock().tick(1000); + vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; @@ -219,7 +225,7 @@ describe('autocompleter', () => { inputElement.value = 'Wor'; inputElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); fixture.detectChanges(); // Select the first item (classic mode: #1) @@ -232,18 +238,19 @@ describe('autocompleter', () => { expect(labelElement).toBeTruthy(); expect(labelElement!.textContent).toContain('#1'); expect(labelElement!.textContent).toContain('Workpackage 1'); - } finally { - jasmine.clock().uninstall(); + } + finally { + vi.useRealTimers(); } }); it('should display semantic formattedId in selected value label', () => { - jasmine.clock().install(); + vi.useFakeTimers(); try { - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); fixture.detectChanges(); fixture.componentInstance.ngAfterViewInit(); - jasmine.clock().tick(1000); + vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; @@ -256,7 +263,7 @@ describe('autocompleter', () => { inputElement.value = 'package 2'; inputElement.dispatchEvent(new Event('input')); fixture.detectChanges(); - jasmine.clock().tick(0); + vi.advanceTimersByTime(0); fixture.detectChanges(); // Select the semantic mode item (PROJ-2) @@ -270,8 +277,9 @@ describe('autocompleter', () => { expect(labelElement!.textContent).toContain('PROJ-2'); expect(labelElement!.textContent).not.toContain('#PROJ-2'); expect(labelElement!.textContent).toContain('Workpackage 2'); - } finally { - jasmine.clock().uninstall(); + } + finally { + vi.useRealTimers(); } }); }); @@ -290,11 +298,11 @@ describe('autocompleter', () => { fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; - expect(select.isOpen()).toBeFalse(); + expect(select.isOpen()).toBe(false); select.open(); select.focus(); - expect(select.isOpen()).toBeTrue(); + expect(select.isOpen()).toBe(true); expect(select.itemsList.items.length).toEqual(0); @@ -307,7 +315,7 @@ describe('autocompleter', () => { await new Promise(resolve => setTimeout(resolve, 100)); expect(getOptionsFnSpy).toHaveBeenCalledWith(''); - getOptionsFnSpy.calls.reset(); + getOptionsFnSpy.mockClear(); inputElement.value = 'Wor'; inputElement.dispatchEvent(new Event('input')); diff --git a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts index f3a3bc9386d..f9993c7a396 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-form/edit-form.spec.ts @@ -33,12 +33,7 @@ import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; class TestEditForm extends EditForm { - constructor( - injector:Injector, - private readonly requireVisibleSpy:(fieldName:string) => Promise, - private readonly activateFieldSpy:() => Promise, - private readonly resetSpy:(fieldName:string, focus?:boolean) => void, - ) { + constructor(injector:Injector, private readonly requireVisibleSpy:(fieldName:string) => Promise, private readonly activateFieldSpy:() => Promise, private readonly resetSpy:(fieldName:string, focus?:boolean) => void) { super(injector); } @@ -61,12 +56,12 @@ class TestEditForm extends EditForm { describe('EditForm', () => { it('does not require visibility twice for newly erroneous inactive fields', async () => { - const tick = jasmine.createSpy('tick'); - const requireVisible = jasmine.createSpy('requireVisible').and.resolveTo(); - const activateField = jasmine.createSpy('activateField').and.resolveTo({} as EditFieldHandler); - const reset = jasmine.createSpy('reset'); + const tick = vi.fn(); + const requireVisible = vi.fn().mockResolvedValue(undefined); + const activateField = vi.fn().mockResolvedValue({} as EditFieldHandler); + const reset = vi.fn(); const injector = { - get: jasmine.createSpy('get').and.callFake((token:unknown) => { + get: vi.fn().mockImplementation((token:unknown) => { if (token === ApplicationRef) { return { tick }; } @@ -79,25 +74,27 @@ describe('EditForm', () => { const change = { inFlight: false, schema: { - ofProperty: jasmine.createSpy('ofProperty').and.returnValue({ + ofProperty: vi.fn().mockReturnValue({ writable: true, name: 'Foo', } as IFieldSchema), }, - getForm: jasmine.createSpy('getForm').and.resolveTo(), + getForm: vi.fn().mockResolvedValue(undefined), }; form.resource = { id: 1 } as unknown as HalResource; form.halEditing = { - changeFor: jasmine.createSpy('changeFor').and.returnValue(change), + changeFor: vi.fn().mockReturnValue(change), } as never; form.halNotification = { - handleRawError: jasmine.createSpy('handleRawError'), - showEditingBlockedError: jasmine.createSpy('showEditingBlockedError'), + handleRawError: vi.fn(), + showEditingBlockedError: vi.fn(), } as never; form.errorsPerAttribute = { foo: ['Required'] }; - (form as unknown as { setErrorsForFields:(fields:string[]) => void }).setErrorsForFields(['foo']); + (form as unknown as { + setErrorsForFields:(fields:string[]) => void; + }).setErrorsForFields(['foo']); await Promise.resolve(); await Promise.resolve(); diff --git a/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts b/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts index 85223cfa119..adf0d829c61 100644 --- a/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts +++ b/frontend/src/app/shared/components/fields/edit/services/global-edit-form-changes-tracker/global-edit-form-changes-tracker.service.spec.ts @@ -20,7 +20,7 @@ describe('GlobalEditFormChangesTrackerService', () => { }); it('should report no changes when empty', () => { - expect(service.thereAreFormsWithUnsavedChanges).toBeFalse(); + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); }); it('should report no changes when one form has no changes', () => { @@ -28,7 +28,7 @@ describe('GlobalEditFormChangesTrackerService', () => { service.addToActiveForms(form); - expect(service.thereAreFormsWithUnsavedChanges).toBeFalse(); + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); }); it('should report no changes when multiple forms have no changes', () => { @@ -40,7 +40,7 @@ describe('GlobalEditFormChangesTrackerService', () => { service.addToActiveForms(form2); service.addToActiveForms(form3); - expect(service.thereAreFormsWithUnsavedChanges).toBeFalse(); + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); }); it('should report no changes when the only form with changes is removed', () => { @@ -49,7 +49,7 @@ describe('GlobalEditFormChangesTrackerService', () => { service.addToActiveForms(form); service.removeFromActiveForms(form); - expect(service.thereAreFormsWithUnsavedChanges).toBeFalse(); + expect(service.thereAreFormsWithUnsavedChanges).toBe(false); }); it('should report changes when one form has changes', () => { @@ -57,7 +57,7 @@ describe('GlobalEditFormChangesTrackerService', () => { service.addToActiveForms(form); - expect(service.thereAreFormsWithUnsavedChanges).toBeTrue(); + expect(service.thereAreFormsWithUnsavedChanges).toBe(true); }); it('should report forms with changes when multiple form have changes', () => { @@ -69,13 +69,13 @@ describe('GlobalEditFormChangesTrackerService', () => { service.addToActiveForms(form2); service.addToActiveForms(form3); - expect(service.thereAreFormsWithUnsavedChanges).toBeTrue(); + expect(service.thereAreFormsWithUnsavedChanges).toBe(true); }); it('should call thereAreFormsWithUnsavedChangesSpy on beforeunload', () => { - const thereAreFormsWithUnsavedChangesSpy = spyOnProperty(service, 'thereAreFormsWithUnsavedChanges', 'get'); + const thereAreFormsWithUnsavedChangesSpy = vi.spyOn(service, 'thereAreFormsWithUnsavedChanges', 'get'); - window.onbeforeunload = jasmine.createSpy(); + window.onbeforeunload = vi.fn(); window.dispatchEvent(new Event('beforeunload')); diff --git a/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts b/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts index d6bd153bc0c..776bcc6d2cf 100644 --- a/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts +++ b/frontend/src/app/shared/components/primer/dynamic-icon.directive.spec.ts @@ -57,6 +57,10 @@ describe('DynamicIconDirective', () => { svgElement = fixture.nativeElement.querySelector('svg'); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should create', () => { component.iconName = 'star'; fixture.detectChanges(); @@ -151,8 +155,8 @@ describe('DynamicIconDirective', () => { }); it('should warn when rendering unknown icon', () => { - spyOn(console, 'warn'); - + vi.spyOn(console, 'warn'); + component.iconName = 'unknown-icon'; fixture.detectChanges(); @@ -160,15 +164,15 @@ describe('DynamicIconDirective', () => { }); it('should not render anything for unknown icon', () => { - spyOn(console, 'warn'); - + vi.spyOn(console, 'warn'); + component.iconName = 'unknown-icon'; fixture.detectChanges(); // Should not have set viewBox or other attributes expect(svgElement.getAttribute('viewBox')).toBeNull(); expect(svgElement.getAttribute('fill')).toBeNull(); - + // Should not have any paths const paths = svgElement.querySelectorAll('path'); @@ -176,28 +180,28 @@ describe('DynamicIconDirective', () => { }); it('should handle empty icon name', () => { - spyOn(console, 'warn'); - + vi.spyOn(console, 'warn'); + component.iconName = ''; fixture.detectChanges(); // Should not warn or render anything expect(console.warn).not.toHaveBeenCalled(); expect(svgElement.getAttribute('viewBox')).toBeNull(); - + const paths = svgElement.querySelectorAll('path'); expect(paths.length).toBe(0); }); it('should only render once when loaded', () => { - spyOn(console, 'warn'); + vi.spyOn(console, 'warn'); const directive = fixture.debugElement.children[0].injector.get(DynamicIconDirective); - spyOn(directive as any, 'renderIcon').and.callThrough(); - + vi.spyOn(directive as any, 'renderIcon'); + component.iconName = 'star'; fixture.detectChanges(); - + // Change icon name after initial load - should not re-render component.iconName = 'x'; fixture.detectChanges(); diff --git a/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.spec.ts b/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.spec.ts index a7c056ea4fd..34bdab2a5f5 100644 --- a/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.spec.ts +++ b/frontend/src/app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component.spec.ts @@ -32,19 +32,12 @@ import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types'; import { SortFilesPipe } from 'core-app/shared/components/storages/pipes/sort-files.pipe'; import { StorageFilesResourceService } from 'core-app/core/state/storage-files/storage-files.service'; import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model'; -import { - FilePickerBaseModalComponent, -} from 'core-app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component'; +import { FilePickerBaseModalComponent, } from 'core-app/shared/components/storages/file-picker-base-modal/file-picker-base-modal.component'; import { StorageFileListItem } from 'core-app/shared/components/storages/storage-file-list-item/storage-file-list-item'; +import type { Mock } from 'vitest'; class TestFilePickerBaseModalComponent extends FilePickerBaseModalComponent { - constructor( - locals:OpModalLocalsMap, - elementRef:ElementRef, - cdRef:ChangeDetectorRef, - sortFilesPipe:SortFilesPipe, - storageFilesResourceService:StorageFilesResourceService, - ) { + constructor(locals:OpModalLocalsMap, elementRef:ElementRef, cdRef:ChangeDetectorRef, sortFilesPipe:SortFilesPipe, storageFilesResourceService:StorageFilesResourceService) { super(locals, elementRef, cdRef, sortFilesPipe, storageFilesResourceService); } @@ -59,10 +52,10 @@ class TestFilePickerBaseModalComponent extends FilePickerBaseModalComponent { describe('FilePickerBaseModalComponent', () => { interface Spies { - detectChanges:jasmine.Spy; - close:jasmine.Spy; - files:jasmine.Spy; - reset:jasmine.Spy; + detectChanges:Mock; + close:Mock; + files:Mock; + reset:Mock; } function buildComponent(spies:Spies) { @@ -84,13 +77,7 @@ describe('FilePickerBaseModalComponent', () => { files: spies.files, reset: spies.reset, } as unknown as StorageFilesResourceService; - const component = new TestFilePickerBaseModalComponent( - locals, - elementRef, - cdRef, - sortFilesPipe, - storageFilesResourceService, - ); + const component = new TestFilePickerBaseModalComponent(locals, elementRef, cdRef, sortFilesPipe, storageFilesResourceService); component.ngOnInit(); @@ -98,15 +85,15 @@ describe('FilePickerBaseModalComponent', () => { } it('cancels pending directory loading on destroy', () => { - const teardown = jasmine.createSpy('teardown'); + const teardown = vi.fn(); const files$ = new Observable(() => teardown); const directory = { location: '/folder', mimeType: 'application/x-op-directory' } as IStorageFile; - const files = jasmine.createSpy('files').and.returnValue(files$); + const files = vi.fn().mockReturnValue(files$); const { component } = buildComponent({ - detectChanges: jasmine.createSpy('detectChanges'), - close: jasmine.createSpy('close'), + detectChanges: vi.fn(), + close: vi.fn(), files, - reset: jasmine.createSpy('reset'), + reset: vi.fn(), }); component.loadDirectory(directory); @@ -121,15 +108,15 @@ describe('FilePickerBaseModalComponent', () => { it('does not report directory loading errors as unhandled async exceptions', async () => { const previousUnhandledError = config.onUnhandledError; - const onUnhandledError = jasmine.createSpy('onUnhandledError'); + const onUnhandledError = vi.fn(); const files$ = throwError(() => new Error('boom')); - const detectChanges = jasmine.createSpy('detectChanges'); + const detectChanges = vi.fn(); const directory = { location: '/folder', mimeType: 'application/x-op-directory' } as IStorageFile; const { component } = buildComponent({ detectChanges, - close: jasmine.createSpy('close'), - files: jasmine.createSpy('files').and.returnValue(files$), - reset: jasmine.createSpy('reset'), + close: vi.fn(), + files: vi.fn().mockReturnValue(files$), + reset: vi.fn(), }); config.onUnhandledError = onUnhandledError; diff --git a/frontend/src/app/shared/helpers/dom-helpers.spec.ts b/frontend/src/app/shared/helpers/dom-helpers.spec.ts index 2f3e5860ff0..6d73e5f4561 100644 --- a/frontend/src/app/shared/helpers/dom-helpers.spec.ts +++ b/frontend/src/app/shared/helpers/dom-helpers.spec.ts @@ -26,12 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { - toggleElement, - toggleElementByClass, - toggleElementByVisibility, - attributeTokenList, -} from './dom-helpers'; +import { toggleElement, toggleElementByClass, toggleElementByVisibility, attributeTokenList, } from './dom-helpers'; describe('dom-helpers', () => { describe('toggleElement', () => { @@ -197,13 +192,13 @@ describe('dom-helpers', () => { it('mimics DOMTokenList over an attribute', () => { const list = attributeTokenList(el, attr); - expect(list.contains('a')).toBeFalse(); + expect(list.contains('a')).toBe(false); expect(el.getAttribute(attr)).toBeNull(); list.add('a', 'b'); - expect(list.contains('a')).toBeTrue(); - expect(list.contains('b')).toBeTrue(); + expect(list.contains('a')).toBe(true); + expect(list.contains('b')).toBe(true); expect(el.getAttribute(attr)).toBe('a b'); // adding duplicates is idempotent @@ -214,25 +209,25 @@ describe('dom-helpers', () => { // remove works list.remove('a'); - expect(list.contains('a')).toBeFalse(); + expect(list.contains('a')).toBe(false); expect(el.getAttribute(attr)).toBe('b'); // toggle without force flips presence and returns the new state - expect(list.toggle('b')).toBeFalse(); // removed + expect(list.toggle('b')).toBe(false); // removed expect(el.getAttribute(attr)).toBe(''); - expect(list.toggle('c')).toBeTrue(); // added + expect(list.toggle('c')).toBe(true); // added expect(el.getAttribute(attr)).toBe('c'); // forced toggle honors force - expect(list.toggle('x', true)).toBeTrue(); - expect(list.contains('x')).toBeTrue(); - expect(list.toggle('x', false)).toBeFalse(); - expect(list.contains('x')).toBeFalse(); + expect(list.toggle('x', true)).toBe(true); + expect(list.contains('x')).toBe(true); + expect(list.toggle('x', false)).toBe(false); + expect(list.contains('x')).toBe(false); // replace swaps tokens and returns true when old exists - expect(list.replace('c', 'd')).toBeTrue(); - expect(list.contains('c')).toBeFalse(); - expect(list.contains('d')).toBeTrue(); + expect(list.replace('c', 'd')).toBe(true); + expect(list.contains('c')).toBe(false); + expect(list.contains('d')).toBe(true); // iterator yields tokens expect([...list]).toEqual(['d']); @@ -241,15 +236,15 @@ describe('dom-helpers', () => { list.value = 'e f'; expect(el.getAttribute(attr)).toBe('e f'); - expect(list.contains('e')).toBeTrue(); - expect(list.contains('f')).toBeTrue(); + expect(list.contains('e')).toBe(true); + expect(list.contains('f')).toBe(true); }); it('replace on non-existent token returns false and does not change tokens', () => { const list = attributeTokenList(el, attr); list.add('a', 'b'); - expect(list.replace('x', 'y')).toBeFalse(); + expect(list.replace('x', 'y')).toBe(false); expect([...list]).toEqual(['a', 'b']); expect(el.getAttribute(attr)).toBe('a b'); }); @@ -326,7 +321,8 @@ describe('dom-helpers', () => { const tokens:string[] = []; for (let i = 0; i < list.length; i++) { const token = list.item(i); - if (token) tokens.push(token); + if (token) + tokens.push(token); } expect(tokens).toEqual(['alpha', 'beta', 'gamma']); diff --git a/frontend/src/stimulus/controllers/check-all.controller.spec.ts b/frontend/src/stimulus/controllers/check-all.controller.spec.ts index a2443f2197f..ada71a74f5c 100644 --- a/frontend/src/stimulus/controllers/check-all.controller.spec.ts +++ b/frontend/src/stimulus/controllers/check-all.controller.spec.ts @@ -54,19 +54,19 @@ describe('CheckAllController', () => { }); const checkAllTemplate = ` -
- - -
- `; +
+ + +
+ `; const checkableTemplate = ` -
- - - -
- `; +
+ + + +
+ `; function appendTemplate(html:string) { const template = document.createElement('template'); @@ -98,18 +98,18 @@ describe('CheckAllController', () => { it('toggles checkboxes', async () => { const inputs = Array.from(document.querySelectorAll('input[type="checkbox"]')); - expect(inputs).toHaveSize(3); - expect(inputs.every((i) => !i.checked)).toBeTrue(); + expect(inputs).toHaveLength(3); + expect(inputs.every((i) => !i.checked)).toBe(true); document.getElementById('check-all')!.click(); await nextFrame(); - expect(inputs.every((i) => i.checked)).toBeTrue(); + expect(inputs.every((i) => i.checked)).toBe(true); document.getElementById('uncheck-all')!.click(); await nextFrame(); - expect(inputs.every((i) => !i.checked)).toBeTrue(); + expect(inputs.every((i) => !i.checked)).toBe(true); }); it('applies aria-controls for connected outlet', () => { diff --git a/frontend/src/stimulus/controllers/checkable.controller.spec.ts b/frontend/src/stimulus/controllers/checkable.controller.spec.ts index 33853f74f73..3b937c1771f 100644 --- a/frontend/src/stimulus/controllers/checkable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/checkable.controller.spec.ts @@ -53,7 +53,7 @@ describe('CheckableController', () => { it('checks all when none are checked', () => { controller.toggleAll(new Event('click')); - expect(inputs.every((i) => i.checked)).toBeTrue(); + expect(inputs.every((i) => i.checked)).toBe(true); }); it('checks all when some are checked (mixed state)', () => { @@ -61,7 +61,7 @@ describe('CheckableController', () => { controller.toggleAll(new Event('click')); - expect(inputs.every((i) => i.checked)).toBeTrue(); + expect(inputs.every((i) => i.checked)).toBe(true); }); it('unchecks all when all are checked', () => { @@ -69,17 +69,17 @@ describe('CheckableController', () => { controller.toggleAll(new Event('click')); - expect(inputs.every((i) => !i.checked)).toBeTrue(); + expect(inputs.every((i) => !i.checked)).toBe(true); }); it('dispatches input event', () => { - const dispatchSpy = spyOn(inputs[0], 'dispatchEvent').and.callThrough(); + const dispatchSpy = vi.spyOn(inputs[0], 'dispatchEvent'); controller.toggleAll(new Event('click')); expect(dispatchSpy).toHaveBeenCalledTimes(1); - const eventArg = dispatchSpy.calls.mostRecent().args[0]; + const eventArg = vi.mocked(dispatchSpy).mock.lastCall![0]; expect(eventArg.type).toBe('input'); expect(eventArg.bubbles).toBe(false); @@ -87,7 +87,7 @@ describe('CheckableController', () => { }); it('checkAll calls toggleChecked(true)', () => { - spyOn(controller, 'toggleChecked').and.callFake(() => {}); + vi.spyOn(controller, 'toggleChecked').mockImplementation(() => { }); controller.checkAll(new Event('click')); @@ -95,7 +95,7 @@ describe('CheckableController', () => { }); it('uncheckAll calls toggleChecked(false)', () => { - spyOn(controller, 'toggleChecked').and.callFake(() => {}); + vi.spyOn(controller, 'toggleChecked').mockImplementation(() => { }); controller.uncheckAll(new Event('click')); @@ -139,9 +139,9 @@ describe('CheckableController', () => { controller.toggleSelection(event); // Only admin checkboxes should be checked - expect(inputs[0].checked).toBeTrue(); - expect(inputs[1].checked).toBeFalse(); - expect(inputs[2].checked).toBeTrue(); + expect(inputs[0].checked).toBe(true); + expect(inputs[1].checked).toBe(false); + expect(inputs[2].checked).toBe(true); }); it('unchecks all matching checkboxes when all are checked', () => { @@ -157,9 +157,9 @@ describe('CheckableController', () => { controller.toggleSelection(event); // Only admin checkboxes should be unchecked - expect(inputs[0].checked).toBeFalse(); - expect(inputs[1].checked).toBeTrue(); // member stays checked - expect(inputs[2].checked).toBeFalse(); + expect(inputs[0].checked).toBe(false); + expect(inputs[1].checked).toBe(true); // member stays checked + expect(inputs[2].checked).toBe(false); }); it('works with numeric value params (converted to string)', () => { @@ -172,9 +172,9 @@ describe('CheckableController', () => { controller.toggleSelection(event); - expect(inputs[0].checked).toBeTrue(); - expect(inputs[1].checked).toBeFalse(); - expect(inputs[2].checked).toBeTrue(); + expect(inputs[0].checked).toBe(true); + expect(inputs[1].checked).toBe(false); + expect(inputs[2].checked).toBe(true); }); it('works with boolean value params (converted to string)', () => { @@ -187,9 +187,9 @@ describe('CheckableController', () => { controller.toggleSelection(event); - expect(inputs[0].checked).toBeTrue(); - expect(inputs[1].checked).toBeFalse(); - expect(inputs[2].checked).toBeTrue(); + expect(inputs[0].checked).toBe(true); + expect(inputs[1].checked).toBe(false); + expect(inputs[2].checked).toBe(true); }); }); }); diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts index 5973cad8a06..d92674ef150 100644 --- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts @@ -37,7 +37,7 @@ describe('GenericDragAndDropController', () => { controller = Object.create(GenericDragAndDropController.prototype) as GenericDragAndDropController; }); - function setValue(name:'handleValue'|'handleSelectorValue', value:boolean|string) { + function setValue(name:'handleValue' | 'handleSelectorValue', value:boolean | string) { Object.defineProperty(controller, name, { value, configurable: true }); } @@ -51,29 +51,20 @@ describe('GenericDragAndDropController', () => { return row; } - function callCanStartDrag(el:Element|null|undefined, handle:Element|null|undefined):boolean { - const canStartDrag = Reflect.get(controller, 'canStartDrag') as ( - this:GenericDragAndDropController, - el:Element|null|undefined, - handle:Element|null|undefined - ) => boolean; + function callCanStartDrag(el:Element | null | undefined, handle:Element | null | undefined):boolean { + const canStartDrag = Reflect.get(controller, 'canStartDrag') as (this:GenericDragAndDropController, el:Element | null | undefined, handle:Element | null | undefined) => boolean; return canStartDrag.call(controller, el, handle); } - function callAriaPressedTarget(el:Element):Element|null { - const ariaPressedTarget = Reflect.get(controller, 'ariaPressedTarget') as ( - this:GenericDragAndDropController, - el:Element - ) => Element|null; + function callAriaPressedTarget(el:Element):Element | null { + const ariaPressedTarget = Reflect.get(controller, 'ariaPressedTarget') as (this:GenericDragAndDropController, el:Element) => Element | null; return ariaPressedTarget.call(controller, el); } function callResolveMirrorContainer():Element { - const resolveMirrorContainer = Reflect.get(controller, 'resolveMirrorContainer') as ( - this:GenericDragAndDropController - ) => Element; + const resolveMirrorContainer = Reflect.get(controller, 'resolveMirrorContainer') as (this:GenericDragAndDropController) => Element; return resolveMirrorContainer.call(controller); } @@ -85,7 +76,7 @@ describe('GenericDragAndDropController', () => { setValue('handleValue', false); setValue('handleSelectorValue', '.DragHandle'); - expect(callCanStartDrag(row, row)).toBeTrue(); + expect(callCanStartDrag(row, row)).toBe(true); }); it('rejects rows that are not draggable in handle-less mode', () => { @@ -96,7 +87,7 @@ describe('GenericDragAndDropController', () => { setValue('handleValue', false); setValue('handleSelectorValue', '.DragHandle'); - expect(callCanStartDrag(row, row)).toBeFalse(); + expect(callCanStartDrag(row, row)).toBe(false); }); it('rejects empty placeholder rows in handle-less mode', () => { @@ -106,7 +97,7 @@ describe('GenericDragAndDropController', () => { setValue('handleValue', false); setValue('handleSelectorValue', '.DragHandle'); - expect(callCanStartDrag(row, row)).toBeFalse(); + expect(callCanStartDrag(row, row)).toBe(false); }); it('rejects interactive descendants in handle-less mode', () => { @@ -117,7 +108,7 @@ describe('GenericDragAndDropController', () => { setValue('handleValue', false); setValue('handleSelectorValue', '.DragHandle'); - expect(callCanStartDrag(row, button)).toBeFalse(); + expect(callCanStartDrag(row, button)).toBe(false); }); it('allows drag handles in handle mode', () => { @@ -129,7 +120,7 @@ describe('GenericDragAndDropController', () => { setValue('handleValue', true); setValue('handleSelectorValue', '.DragHandle'); - expect(callCanStartDrag(row, handle)).toBeTrue(); + expect(callCanStartDrag(row, handle)).toBe(true); }); }); diff --git a/frontend/src/stimulus/controllers/truncation.controller.spec.ts b/frontend/src/stimulus/controllers/truncation.controller.spec.ts index 11082b349b5..b8cfeccc684 100644 --- a/frontend/src/stimulus/controllers/truncation.controller.spec.ts +++ b/frontend/src/stimulus/controllers/truncation.controller.spec.ts @@ -68,17 +68,17 @@ describe('TruncationController', () => { }); const truncationTemplate = ` -
-
- - This is a very long text that should be truncated when it exceeds the container width - -
-
- -
-
- `; +
+
+ + This is a very long text that should be truncated when it exceeds the container width + +
+
+ +
+
+ `; function appendTemplate(html:string) { const template = document.createElement('template'); @@ -93,10 +93,7 @@ describe('TruncationController', () => { }); it('connects successfully', () => { - const controller = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); expect(controller).toBeDefined(); }); @@ -111,17 +108,14 @@ describe('TruncationController', () => { it('adds Truncate--expanded class when expanded value is true', async () => { const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; - expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); - const controller:any = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); controller.expandedValue = true; await nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true); }); }); @@ -135,20 +129,20 @@ describe('TruncationController', () => { const button = document.querySelector('[data-truncation-target="expander"] button')!; const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; - expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); expect(button.getAttribute('aria-expanded')).toBe('false'); button.click(); await nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true); expect(button.getAttribute('aria-expanded')).toBe('true'); expect(button.getAttribute('aria-label')).toBe('Collapse text'); button.click(); await nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); expect(button.getAttribute('aria-expanded')).toBe('false'); expect(button.getAttribute('aria-label')).toBe('Expand text'); }); @@ -162,10 +156,7 @@ describe('TruncationController', () => { it('updates aria-label when expanded', async () => { const button = document.querySelector('[data-truncation-target="expander"] button')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); expect(button.getAttribute('aria-label')).toBe('Expand text'); @@ -177,10 +168,7 @@ describe('TruncationController', () => { it('updates aria-expanded attribute', async () => { const button = document.querySelector('[data-truncation-target="expander"] button')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); expect(button.getAttribute('aria-expanded')).toBe('false'); @@ -192,22 +180,19 @@ describe('TruncationController', () => { it('toggles Truncate--expanded class', async () => { const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); - expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); controller.expandedValue = true; await nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBeTrue(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true); controller.expandedValue = false; await nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBeFalse(); + expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); }); }); @@ -221,17 +206,17 @@ describe('TruncationController', () => { it('hides expander when content is not truncated', async () => { const shortTextTemplate = ` -
-
- - Short text - -
-
- -
-
- `; +
+
+ + Short text + +
+
+ +
+
+ `; appendTemplate(shortTextTemplate); await waitForResize(); @@ -239,58 +224,60 @@ describe('TruncationController', () => { const expander = document.querySelector('[data-truncation-target="expander"]')!; // When content is not truncated, expander should be hidden - expect(expander.hidden).toBeTrue(); + expect(expander.hidden).toBe(true); }); it('shows expander when content is truncated', async () => { const longTextTemplate = ` -
-
- - This is a very long text that should definitely be truncated - -
-
- -
-
- `; +
+
+ + This is a very long text that should definitely be truncated + +
+
+ +
+
+ `; appendTemplate(longTextTemplate); + + const truncateText = document.querySelector('.Truncate-text')!; + Object.defineProperty(truncateText, 'scrollWidth', { value: 300, configurable: true }); + Object.defineProperty(truncateText, 'clientWidth', { value: 50, configurable: true }); + await waitForResize(); const expander = document.querySelector('[data-truncation-target="expander"]')!; // When content is truncated, expander should be visible - expect(expander.hidden).toBeFalse(); + expect(expander.hidden).toBe(false); }); }); describe('resize() method', () => { it('calls update() when resize is triggered', async () => { const template = ` -
-
- - Test text - -
-
- -
-
- `; +
+
+ + Test text + +
+
+ +
+
+ `; appendTemplate(template); await nextFrame(); - const controller:any = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); // Spy on the private update method to verify resize() calls it - const updateSpy = spyOn(controller, 'update').and.callThrough(); + const updateSpy = vi.spyOn(controller, 'update'); controller.resize(); @@ -299,25 +286,22 @@ describe('TruncationController', () => { it('updates expander visibility when content dimensions change', async () => { const template = ` -
-
- - Test - -
-
- -
-
- `; +
+
+ + Test + +
+
+ +
+
+ `; appendTemplate(template); await nextFrame(); - const controller:any = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); const expander = document.querySelector('[data-truncation-target="expander"]')!; const truncateText = document.querySelector('.Truncate-text')!; @@ -330,21 +314,21 @@ describe('TruncationController', () => { Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 }); controller.resize(); - expect(expander.hidden).toBeTrue(); + expect(expander.hidden).toBe(true); // Simulate truncated: scrollWidth > clientWidth Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 200 }); Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 }); controller.resize(); - expect(expander.hidden).toBeFalse(); + expect(expander.hidden).toBe(false); // Simulate not truncated again Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 50 }); Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 50 }); controller.resize(); - expect(expander.hidden).toBeTrue(); + expect(expander.hidden).toBe(true); // Restore original descriptors if (originalScrollWidth) { @@ -357,38 +341,35 @@ describe('TruncationController', () => { it('keeps expander visible when expanded even if not truncated', async () => { const template = ` -
-
- - Short - -
-
- -
-
- `; +
+
+ + Short + +
+
+ +
+
+ `; appendTemplate(template); await nextFrame(); - const controller:any = Stimulus.getControllerForElementAndIdentifier( - document.querySelector('[data-controller="truncation"]')!, - 'truncation', - ); + const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); const expander = document.querySelector('[data-truncation-target="expander"]')!; // Initially short text, expander should be hidden controller.resize(); - expect(expander.hidden).toBeTrue(); + expect(expander.hidden).toBe(true); // Expand the text controller.expandedValue = true; await nextFrame(); // When expanded, expander should remain visible even if not truncated - expect(expander.hidden).toBeFalse(); + expect(expander.hidden).toBe(false); }); }); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index bd280168ac3..665287ef166 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1,4 +1,5 @@ import { I18n } from 'i18n-js'; +import lodash from 'lodash'; import { registerDialogStreamAction } from 'core-turbo/dialog-stream-action'; registerDialogStreamAction(); @@ -7,3 +8,42 @@ registerDialogStreamAction(); (window as any).global = window; window.I18n = new I18n(); + +// Production code expects `_` to be available globally (set in init-vendors.ts). +// Mirror that here so production modules pulled in by spec compilation can run. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any)._ = lodash; + +// jsdom does not implement CSS.escape; production helpers (e.g. getMetaElement) +// call it unconditionally. +if (typeof CSS === 'undefined' || typeof CSS.escape !== 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).CSS = (globalThis as any).CSS || {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).CSS.escape = (value:string) => String(value).replace(/[^a-zA-Z0-9_\-]/g, (ch) => `\\${ch}`); +} + +// jsdom does not implement ResizeObserver. +if (typeof (globalThis as any).ResizeObserver === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +// jsdom does not implement HTMLDialogElement.showModal/close. +if (typeof HTMLDialogElement !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proto = HTMLDialogElement.prototype as any; + if (typeof proto.showModal !== 'function') { + proto.showModal = function showModal() { this.open = true; }; + } + if (typeof proto.show !== 'function') { + proto.show = function show() { this.open = true; }; + } + if (typeof proto.close !== 'function') { + proto.close = function close() { this.open = false; }; + } +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json index 1e14aa0ae16..48d3e2416b9 100644 --- a/frontend/tsconfig.spec.json +++ b/frontend/tsconfig.spec.json @@ -8,10 +8,13 @@ }, "files": [ "src/test-setup.ts", + "src/test-providers.ts", "src/polyfills.ts" ], "include": [ "src/**/*.spec.ts", - "src/**/*.d.ts" + "src/**/*.d.ts", + "src/main.ts", + "src/app/**/*.module.ts" ] } diff --git a/modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.spec.ts b/modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.spec.ts index 4a893ed81d8..571ae4e9a18 100644 --- a/modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.spec.ts +++ b/modules/github_integration/frontend/module/git-actions-menu/git-actions-menu.component.spec.ts @@ -1,23 +1,23 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DebugElement } from '@angular/core'; -import { GitActionsMenuComponent } from "./git-actions-menu.component"; -import { GitActionsService } from "../git-actions/git-actions.service"; -import { By } from "@angular/platform-browser"; -import { OpIconComponent } from "core-app/shared/components/icon/icon.component"; -import { I18nService } from "core-app/core/i18n/i18n.service"; -import { OpContextMenuLocalsToken } from "core-app/shared/components/op-context-menu/op-context-menu.types"; +import { GitActionsMenuComponent } from './git-actions-menu.component'; +import { GitActionsService } from '../git-actions/git-actions.service'; +import { By } from '@angular/platform-browser'; +import { OpIconComponent } from 'core-app/shared/components/icon/icon.component'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { OpContextMenuLocalsToken } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; describe('GitActionsMenuComponent', () => { let component:GitActionsMenuComponent; let fixture:ComponentFixture; let element:DebugElement; - let gitActionsService:jasmine.SpyObj; + let gitActionsService:{ gitCommand:ReturnType; commitMessage:ReturnType; commitMessageDisplayText:ReturnType; branchName:ReturnType }; const I18nServiceStub = { - t: function(key:string) { + t: function (key:string) { return 'test translation'; } - } + }; const localsStub = { workPackage: 1, items: [ @@ -28,23 +28,28 @@ describe('GitActionsMenuComponent', () => { linkText: 'linkText', } ] - } + }; beforeEach(async () => { - const gitActionsServiceSpy = jasmine.createSpyObj('GitActionsService', ['gitCommand', 'commitMessage', 'commitMessageDisplayText', 'branchName']); + const gitActionsServiceSpy = { + gitCommand: vi.fn().mockName('GitActionsService.gitCommand'), + commitMessage: vi.fn().mockName('GitActionsService.commitMessage'), + commitMessageDisplayText: vi.fn().mockName('GitActionsService.commitMessageDisplayText'), + branchName: vi.fn().mockName('GitActionsService.branchName') + }; await TestBed .configureTestingModule({ - declarations: [ - GitActionsMenuComponent, - OpIconComponent, - ], - providers: [ - { provide: I18nService, useValue: I18nServiceStub }, - { provide: OpContextMenuLocalsToken, useValue: localsStub }, - { provide: GitActionsService, useValue: gitActionsServiceSpy }, - ], - }) + declarations: [ + GitActionsMenuComponent, + OpIconComponent, + ], + providers: [ + { provide: I18nService, useValue: I18nServiceStub }, + { provide: OpContextMenuLocalsToken, useValue: localsStub }, + { provide: GitActionsService, useValue: gitActionsServiceSpy }, + ], + }) .compileComponents(); }); @@ -52,7 +57,7 @@ describe('GitActionsMenuComponent', () => { fixture = TestBed.createComponent(GitActionsMenuComponent); component = fixture.componentInstance; element = fixture.debugElement; - gitActionsService = fixture.debugElement.injector.get(GitActionsService) as jasmine.SpyObj; + gitActionsService = fixture.debugElement.injector.get(GitActionsService) as unknown as typeof gitActionsService; fixture.detectChanges(); }); @@ -64,7 +69,7 @@ describe('GitActionsMenuComponent', () => { it('should generate the branch name on copy button click', () => { const copyButton = fixture.debugElement.query(By.css('.copy-button')).nativeElement; - gitActionsService.branchName.and.returnValue('test branch'); + gitActionsService.branchName.mockReturnValue('test branch'); copyButton.click(); fixture.detectChanges(); diff --git a/modules/github_integration/frontend/module/git-actions/git-actions.service.spec.ts b/modules/github_integration/frontend/module/git-actions/git-actions.service.spec.ts index 7ed0ec4b8c2..ae4b2bb2467 100644 --- a/modules/github_integration/frontend/module/git-actions/git-actions.service.spec.ts +++ b/modules/github_integration/frontend/module/git-actions/git-actions.service.spec.ts @@ -27,10 +27,10 @@ //++ import { GitActionsService } from './git-actions.service'; -import { WorkPackageResource } from "core-app/features/hal/resources/work-package-resource"; -import { PathHelperService } from "core-app/core/path-helper/path-helper.service"; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -describe('GitActionsService', function() { +describe('GitActionsService', function () { let service:GitActionsService; const createWorkPackage = (overrides = {}) => { @@ -44,7 +44,7 @@ describe('GitActionsService', function() { pathHelper: new PathHelperService() }; const workPackage = { ...defaults, ...overrides }; - return(workPackage as WorkPackageResource); + return (workPackage as WorkPackageResource); }; beforeEach(() => { @@ -54,21 +54,20 @@ describe('GitActionsService', function() { it('produces a branch name, commit message, and a git command', () => { const wp = createWorkPackage(); - expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t'); - expect(service.commitMessage(wp)).toEqual( - `[#42] Find the question, or don't + const origin = window.location.origin; -http://localhost:9876/wp/42` - ); - expect(service.gitCommand(wp)).toEqual( - `git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m '[#42] Find the question, or don'\\''t' -m 'http://localhost:9876/wp/42'` - ); + expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t'); + expect(service.commitMessage(wp)).toEqual(`[#42] Find the question, or don't + +${origin}/wp/42`); + + expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m '[#42] Find the question, or don'\\''t' -m '${origin}/wp/42'`); }); it('shell-escapes output for the git-command', () => { const wp = createWorkPackage({ subject: "' && rm -rf / #" }); - expect(service.gitCommand(wp)).toEqual( - `git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m '[#42] '\\'' && rm -rf / #' -m 'http://localhost:9876/wp/42'` - ); + const origin = window.location.origin; + + expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m '[#42] '\\'' && rm -rf / #' -m '${origin}/wp/42'`); }); }); diff --git a/modules/github_integration/frontend/module/github-tab/github-tab.component.spec.ts b/modules/github_integration/frontend/module/github-tab/github-tab.component.spec.ts index 0e0fce00cfd..898c27dcec0 100644 --- a/modules/github_integration/frontend/module/github-tab/github-tab.component.spec.ts +++ b/modules/github_integration/frontend/module/github-tab/github-tab.component.spec.ts @@ -1,37 +1,55 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DebugElement } from '@angular/core'; -import { GitHubTabComponent } from "core-app/features/plugins/linked/openproject-github_integration/github-tab/github-tab.component"; -import { TabPrsComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component"; -import { TabHeaderComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-header/tab-header.component"; -import { By } from "@angular/platform-browser"; -import { I18nService } from "core-app/core/i18n/i18n.service"; -import { PathHelperService } from "core-app/core/path-helper/path-helper.service"; +import { Component, DebugElement, Input } from '@angular/core'; +import { GitHubTabComponent } from 'core-app/features/plugins/linked/openproject-github_integration/github-tab/github-tab.component'; +import { By } from '@angular/platform-browser'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +@Component({ + selector: 'tab-header', + template: '', + standalone: false, +}) +class TabHeaderStubComponent { + @Input() workPackage:WorkPackageResource; +} + +@Component({ + selector: 'op-tab-prs', + template: '', + standalone: false, +}) +class TabPrsStubComponent { + @Input() workPackage:WorkPackageResource; +} describe('GitHubTabComponent.', () => { let component:GitHubTabComponent; let fixture:ComponentFixture; let element:DebugElement; + const workPackage = { id: 'testId' } as WorkPackageResource; const apiV3Base = 'http://www.openproject.com/api/v3/'; - const IPathHelperServiceStub = { api:{ v3: { apiV3Base }}}; + const IPathHelperServiceStub = { api: { v3: { apiV3Base } } }; const I18nServiceStub = { - t: function(key:string) { + t: function (key:string) { return 'test translation'; } - } + }; beforeEach(async () => { await TestBed .configureTestingModule({ - declarations: [ - TabPrsComponent, - TabHeaderComponent, - ], - providers: [ - { provide: I18nService, useValue: I18nServiceStub }, - { provide: PathHelperService, useValue: IPathHelperServiceStub }, - ], - }) + declarations: [ + GitHubTabComponent, + TabHeaderStubComponent, + TabPrsStubComponent, + ], + providers: [ + { provide: I18nService, useValue: I18nServiceStub }, + { provide: PathHelperService, useValue: IPathHelperServiceStub }, + ], + }) .compileComponents(); }); @@ -39,6 +57,7 @@ describe('GitHubTabComponent.', () => { fixture = TestBed.createComponent(GitHubTabComponent); component = fixture.componentInstance; element = fixture.debugElement; + component.workPackage = workPackage; fixture.detectChanges(); }); @@ -54,4 +73,12 @@ describe('GitHubTabComponent.', () => { expect(tabHeader).toBeTruthy(); expect(tabPrs).toBeTruthy(); }); + + it('should pass the work package to the child components', () => { + const tabHeader = fixture.debugElement.query(By.directive(TabHeaderStubComponent)); + const tabPrs = fixture.debugElement.query(By.directive(TabPrsStubComponent)); + + expect(tabHeader.componentInstance.workPackage).toBe(workPackage); + expect(tabPrs.componentInstance.workPackage).toBe(workPackage); + }); }); diff --git a/modules/github_integration/frontend/module/pull-request/pull-request.component.spec.ts b/modules/github_integration/frontend/module/pull-request/pull-request.component.spec.ts index 38132c541be..c439bdfb0e6 100644 --- a/modules/github_integration/frontend/module/pull-request/pull-request.component.spec.ts +++ b/modules/github_integration/frontend/module/pull-request/pull-request.component.spec.ts @@ -1,18 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Component, DebugElement, Input } from '@angular/core'; -import { By } from "@angular/platform-browser"; -import { PullRequestComponent } from "./pull-request.component"; +import { By } from '@angular/platform-browser'; +import { PullRequestComponent } from './pull-request.component'; import { OpIconComponent } from 'core-app/shared/components/icon/icon.component'; import { IGithubCheckRunResource, IGithubPullRequest, IGithubUserResource } from '../state/github-pull-request.model'; import { PullRequestStateComponent } from './pull-request-state.component'; @Component({ selector: 'op-date-time', - template: ``, + template: '', standalone: false, }) class OpDateTimeComponent { - @Input('dateTimeValue') dateTimeValue:any; + @Input() + dateTimeValue:any; } describe('PullRequestComponent', () => { @@ -39,10 +40,10 @@ describe('PullRequestComponent', () => { const pullRequestStub:IGithubPullRequest = { id: 3, additionsCount: 3, - body:{ + body: { format: '', raw: 'test raw', - html:'

test

', + html: '

test

', }, changedFilesCount: 3, commentsCount: 3, @@ -74,20 +75,20 @@ describe('PullRequestComponent', () => { _embedded: { githubUser, mergedBy: githubUser, - checkRuns:[checkRun], + checkRuns: [checkRun], } - } + }; beforeEach(async () => { await TestBed .configureTestingModule({ - declarations: [ - PullRequestComponent, - OpDateTimeComponent, - OpIconComponent, - PullRequestStateComponent, - ], - }) + declarations: [ + PullRequestComponent, + OpDateTimeComponent, + OpIconComponent, + PullRequestStateComponent, + ], + }) .compileComponents(); }); diff --git a/modules/github_integration/frontend/module/tab-header/tab-header.component.spec.ts b/modules/github_integration/frontend/module/tab-header/tab-header.component.spec.ts index 1069a13f4a9..2b6a4c68934 100644 --- a/modules/github_integration/frontend/module/tab-header/tab-header.component.spec.ts +++ b/modules/github_integration/frontend/module/tab-header/tab-header.component.spec.ts @@ -1,11 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DebugElement } from '@angular/core'; -import { TabHeaderComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-header/tab-header.component"; -import { By } from "@angular/platform-browser"; -import { OpIconComponent } from "core-app/shared/components/icon/icon.component"; -import { GitActionsMenuDirective } from "core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive"; -import { OPContextMenuService } from "core-app/shared/components/op-context-menu/op-context-menu.service"; -import { I18nService } from "core-app/core/i18n/i18n.service"; +import { TabHeaderComponent } from 'core-app/features/plugins/linked/openproject-github_integration/tab-header/tab-header.component'; +import { By } from '@angular/platform-browser'; +import { OpIconComponent } from 'core-app/shared/components/icon/icon.component'; +import { GitActionsMenuDirective } from 'core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive'; +import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; describe('TabHeaderComponent', () => { @@ -13,29 +13,31 @@ describe('TabHeaderComponent', () => { let fixture:ComponentFixture; let element:DebugElement; const I18nServiceStub = { - t: function(key:string) { + t: function (key:string) { return 'test translation'; } - } - let oPContextMenuService:jasmine.SpyObj; + }; + let oPContextMenuService:{ show:ReturnType }; // @ts-ignore - window.Mousetrap = () => () => {}; + window.Mousetrap = () => () => { }; beforeEach(async () => { - const oPContextMenuServiceSpy = jasmine.createSpyObj('OPContextMenuService', ['show']); + const oPContextMenuServiceSpy = { + show: vi.fn().mockName('OPContextMenuService.show') + }; await TestBed .configureTestingModule({ - declarations: [ - TabHeaderComponent, - OpIconComponent, - GitActionsMenuDirective, - ], - providers: [ - { provide: I18nService, useValue: I18nServiceStub }, - { provide: OPContextMenuService, useValue: oPContextMenuServiceSpy }, - ], - }) + declarations: [ + TabHeaderComponent, + OpIconComponent, + GitActionsMenuDirective, + ], + providers: [ + { provide: I18nService, useValue: I18nServiceStub }, + { provide: OPContextMenuService, useValue: oPContextMenuServiceSpy }, + ], + }) .compileComponents(); }); @@ -43,7 +45,7 @@ describe('TabHeaderComponent', () => { fixture = TestBed.createComponent(TabHeaderComponent); component = fixture.componentInstance; element = fixture.debugElement; - oPContextMenuService = fixture.debugElement.injector.get(OPContextMenuService) as jasmine.SpyObj; + oPContextMenuService = fixture.debugElement.injector.get(OPContextMenuService) as unknown as typeof oPContextMenuService; fixture.detectChanges(); }); diff --git a/modules/github_integration/frontend/module/tab-prs/tab-prs.component.spec.ts b/modules/github_integration/frontend/module/tab-prs/tab-prs.component.spec.ts index 232aef1d3ff..b222459aa5c 100644 --- a/modules/github_integration/frontend/module/tab-prs/tab-prs.component.spec.ts +++ b/modules/github_integration/frontend/module/tab-prs/tab-prs.component.spec.ts @@ -1,14 +1,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ChangeDetectorRef, Component, DebugElement, Input } from '@angular/core'; -import { OpIconComponent } from "core-app/shared/components/icon/icon.component"; -import { GitActionsMenuDirective } from "core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive"; -import { TabPrsComponent } from "core-app/features/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component"; +import { OpIconComponent } from 'core-app/shared/components/icon/icon.component'; +import { GitActionsMenuDirective } from 'core-app/features/plugins/linked/openproject-github_integration/git-actions-menu/git-actions-menu.directive'; +import { TabPrsComponent } from 'core-app/features/plugins/linked/openproject-github_integration/tab-prs/tab-prs.component'; import { GithubPullRequestResourceService } from '../state/github-pull-request.service'; -import { ApiV3Service } from "core-app/core/apiv3/api-v3.service"; -import { of } from "rxjs"; -import { PullRequestComponent } from "core-app/features/plugins/linked/openproject-github_integration/pull-request/pull-request.component"; -import { By } from "@angular/platform-browser"; -import { I18nService } from "core-app/core/i18n/i18n.service"; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { of } from 'rxjs'; +import { PullRequestComponent } from 'core-app/features/plugins/linked/openproject-github_integration/pull-request/pull-request.component'; +import { By } from '@angular/platform-browser'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; import { IGithubPullRequest } from '../state/github-pull-request.model'; import { PullRequestStateComponent } from '../pull-request/pull-request-state.component'; @@ -26,18 +26,18 @@ describe('TabPrsComponent', () => { let component:TabPrsComponent; let fixture:ComponentFixture; let element:DebugElement; - let githubPullRequestResourceServiceSpy:jasmine.SpyObj; - let changeDetectorRef: jasmine.SpyObj; + let githubPullRequestResourceServiceSpy:{ ofWorkPackage:ReturnType }; + let changeDetectorRef:ChangeDetectorRef & { detectChanges:ReturnType }; const I18nServiceStub = { - t: function(key:string) { + t: function (key:string) { return 'test translation'; } - } + }; const ApiV3Stub = { work_packages: { - id: () => ({github_pull_requests: 'prpath'}) + id: () => ({ github_pull_requests: 'prpath' }) } - } + }; const pullRequests:IGithubPullRequest[] = [ { @@ -123,28 +123,32 @@ describe('TabPrsComponent', () => { ]; beforeEach(async () => { - const changeDetectorSpy = jasmine.createSpyObj('ChangeDetectorRef', ['detectChanges']); - githubPullRequestResourceServiceSpy = jasmine.createSpyObj('GithubPullRequestResourceService', ['ofWorkPackage']); + const changeDetectorSpy = { + detectChanges: vi.fn().mockName('ChangeDetectorRef.detectChanges') + }; + githubPullRequestResourceServiceSpy = { + ofWorkPackage: vi.fn().mockName('GithubPullRequestResourceService.ofWorkPackage') + }; // @ts-ignore - githubPullRequestResourceServiceSpy.ofWorkPackage.and.returnValue(of(pullRequests)); + githubPullRequestResourceServiceSpy.ofWorkPackage.mockReturnValue(of(pullRequests)); await TestBed .configureTestingModule({ - declarations: [ - TabPrsComponent, - OpIconComponent, - GitActionsMenuDirective, - PullRequestComponent, - PullRequestStateComponent, - OpDateTimeComponent, - ], - providers: [ - { provide: I18nService, useValue: I18nServiceStub }, - { provide: ApiV3Service, useValue: ApiV3Stub }, - { provide: ChangeDetectorRef, useValue: changeDetectorSpy }, - { provide: GithubPullRequestResourceService, useValue: githubPullRequestResourceServiceSpy }, - ], - }) + declarations: [ + TabPrsComponent, + OpIconComponent, + GitActionsMenuDirective, + PullRequestComponent, + PullRequestStateComponent, + OpDateTimeComponent, + ], + providers: [ + { provide: I18nService, useValue: I18nServiceStub }, + { provide: ApiV3Service, useValue: ApiV3Stub }, + { provide: ChangeDetectorRef, useValue: changeDetectorSpy }, + { provide: GithubPullRequestResourceService, useValue: githubPullRequestResourceServiceSpy }, + ], + }) .compileComponents(); }); @@ -152,7 +156,7 @@ describe('TabPrsComponent', () => { fixture = TestBed.createComponent(TabPrsComponent); component = fixture.componentInstance; element = fixture.debugElement; - changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef) as jasmine.SpyObj; + changeDetectorRef = fixture.debugElement.injector.get(ChangeDetectorRef) as ChangeDetectorRef & { detectChanges:ReturnType }; // @ts-ignore component.workPackage = { id: 'testId' }; diff --git a/modules/gitlab_integration/frontend/module/git-actions/git-actions.service.spec.ts b/modules/gitlab_integration/frontend/module/git-actions/git-actions.service.spec.ts index 52aea2dd662..848f56be33e 100644 --- a/modules/gitlab_integration/frontend/module/git-actions/git-actions.service.spec.ts +++ b/modules/gitlab_integration/frontend/module/git-actions/git-actions.service.spec.ts @@ -27,10 +27,10 @@ //++ import { GitActionsService } from './git-actions.service'; -import { WorkPackageResource } from "core-app/features/hal/resources/work-package-resource"; -import { PathHelperService } from "core-app/core/path-helper/path-helper.service"; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -describe('GitActionsService', function() { +describe('GitActionsService', function () { let service:GitActionsService; const createWorkPackage = (overrides = {}) => { @@ -44,7 +44,7 @@ describe('GitActionsService', function() { pathHelper: new PathHelperService() }; const workPackage = { ...defaults, ...overrides }; - return(workPackage as WorkPackageResource); + return (workPackage as WorkPackageResource); }; beforeEach(() => { @@ -54,21 +54,20 @@ describe('GitActionsService', function() { it('produces a branch name, commit message, and a git command', () => { const wp = createWorkPackage(); - expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t'); - expect(service.commitMessage(wp)).toEqual( - `OP#42 Find the question, or don't + const origin = window.location.origin; -http://localhost:9876/work_packages/42` - ); - expect(service.gitCommand(wp)).toEqual( - `git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m 'OP#42 Find the question, or don'\\''t' -m 'http://localhost:9876/work_packages/42'` - ); + expect(service.branchName(wp)).toEqual('user-story/42-find-the-question-or-don-t'); + expect(service.commitMessage(wp)).toEqual(`OP#42 Find the question, or don't + +${origin}/work_packages/42`); + + expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-find-the-question-or-don-t' && git commit --allow-empty -m 'OP#42 Find the question, or don'\\''t' -m '${origin}/work_packages/42'`); }); it('shell-escapes output for the git-command', () => { const wp = createWorkPackage({ subject: "' && rm -rf / #" }); - expect(service.gitCommand(wp)).toEqual( - `git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m 'OP#42 '\\'' && rm -rf / #' -m 'http://localhost:9876/work_packages/42'` - ); + const origin = window.location.origin; + + expect(service.gitCommand(wp)).toEqual(`git checkout -b 'user-story/42-and-and-rm-rf' && git commit --allow-empty -m 'OP#42 '\\'' && rm -rf / #' -m '${origin}/work_packages/42'`); }); });