diff --git a/frontend/angular.json b/frontend/angular.json index eaab10211db..3798b3de6ff 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -141,6 +141,7 @@ "options": { "tsConfig": "tsconfig.spec.json", "buildTarget": "OpenProject:build", + "runnerConfig": true, "providersFile": "src/test-providers.ts", "setupFiles": ["src/test-browser-polyfills.ts", "src/test-setup.ts"], "browsers": ["chromium", "firefox", "webkit"], 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 162d2974426..e22dd907825 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 @@ -81,6 +81,7 @@ describe('autocompleter', () => { afterEach(() => { delete (window as WindowWithOpenProject).OpenProject; + vi.restoreAllMocks(); }); beforeEach(async () => { @@ -127,9 +128,7 @@ describe('autocompleter', () => { it('should load items', () => { vi.useFakeTimers(); try { - vi.advanceTimersByTime(0); fixture.detectChanges(); - fixture.componentInstance.ngAfterViewInit(); vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; @@ -182,9 +181,7 @@ describe('autocompleter', () => { it('should display formattedId in dropdown options', () => { vi.useFakeTimers(); try { - vi.advanceTimersByTime(0); fixture.detectChanges(); - fixture.componentInstance.ngAfterViewInit(); vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; @@ -215,11 +212,10 @@ describe('autocompleter', () => { }); it('should display classic formattedId in selected value label', () => { + silenceDestroyedOutputWarning(); vi.useFakeTimers(); try { - vi.advanceTimersByTime(0); fixture.detectChanges(); - fixture.componentInstance.ngAfterViewInit(); vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; @@ -237,8 +233,7 @@ describe('autocompleter', () => { fixture.detectChanges(); // Select the first item (classic mode: #1) - const firstOption = document.querySelector('.ng-option')!; - firstOption.click(); + select.select(select.itemsList.items[0]); fixture.detectChanges(); const labelElement = document.querySelector('.ng-value-label'); @@ -253,11 +248,10 @@ describe('autocompleter', () => { }); it('should display semantic formattedId in selected value label', () => { + silenceDestroyedOutputWarning(); vi.useFakeTimers(); try { - vi.advanceTimersByTime(0); fixture.detectChanges(); - fixture.componentInstance.ngAfterViewInit(); vi.advanceTimersByTime(1000); fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; @@ -275,8 +269,7 @@ describe('autocompleter', () => { fixture.detectChanges(); // Select the semantic mode item (PROJ-2) - const option = document.querySelector('.ng-option')!; - option.click(); + select.select(select.itemsList.items[0]); fixture.detectChanges(); const labelElement = document.querySelector('.ng-value-label'); @@ -299,7 +292,6 @@ describe('autocompleter', () => { it('should load items with debounce', async () => { fixture.detectChanges(); - fixture.componentInstance.ngAfterViewInit(); // Wait for ngAfterViewInit's internal setTimeout(25ms) and debounce to fire. await new Promise(resolve => setTimeout(resolve, 100)); @@ -339,6 +331,25 @@ describe('autocompleter', () => { }); }); +// NG0953 ("Unexpected emit for destroyed OutputRef") is emitted when ng-select +// emits on an OutputRef during fixture teardown under fake timers. It is a real +// lifecycle smell, not pure noise — silencing it here is a pragmatic stopgap so +// these specs stay readable, not a fix. The proper fix is to stop the +// emit-after-destroy in the component teardown path; until then this is +// scoped to the two affected specs and passes every other warning through so it +// does not hide unrelated regressions. Do not promote this to a global filter. +function silenceDestroyedOutputWarning():void { + const originalWarn = console.warn.bind(console); + + vi.spyOn(console, 'warn').mockImplementation((message?:unknown, ...args:unknown[]) => { + if (typeof message === 'string' && message.startsWith('NG0953: Unexpected emit for destroyed `OutputRef`')) { + return; + } + + originalWarn(message, ...args); + }); +} + describe('derived autocompleter', () => { beforeEach(async () => { await TestBed.configureTestingModule({ 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 e7cf25d086b..dfb8a82781d 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 @@ -30,10 +30,11 @@ import { ApplicationRef, Injector } from '@angular/core'; import { EditForm } from 'core-app/shared/components/fields/edit/edit-form/edit-form'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { EditFieldHandler } from 'core-app/shared/components/fields/edit/editing-portal/edit-field-handler'; -import { vi } from 'vitest'; +import { afterEach, vi } from 'vitest'; +import type { 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 resetSpy:(fieldName:string, focus?:boolean) => void) { super(injector); } @@ -41,8 +42,10 @@ class TestEditForm extends EditForm { return this.requireVisibleSpy(fieldName); } - public activateField():Promise { - return this.activateFieldSpy(); + // Concrete implementation required by the abstract base. The specs mock + // `activate` directly, so this is never invoked. + protected activateField(_form:EditForm, _schema:IFieldSchema, _fieldName:string, _errors:string[]):Promise { + return Promise.resolve({} as EditFieldHandler); } public reset(fieldName:string, focus?:boolean):void { @@ -55,10 +58,13 @@ class TestEditForm extends EditForm { } describe('EditForm', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('does not require visibility twice for newly erroneous inactive fields', async () => { const tick = vi.fn(); const requireVisible = vi.fn().mockResolvedValue(undefined); - const activateField = vi.fn().mockResolvedValue({}); const reset = vi.fn(); const injector = { get: vi.fn().mockImplementation((token:unknown) => { @@ -70,7 +76,8 @@ describe('EditForm', () => { }), }; - const form = new TestEditForm(injector, requireVisible, activateField, reset); + const form = new TestEditForm(injector, requireVisible, reset); + const activate = vi.spyOn(form, 'activate').mockResolvedValue({} as EditFieldHandler); const change = { inFlight: false, schema: { @@ -91,13 +98,17 @@ describe('EditForm', () => { showEditingBlockedError: vi.fn(), } as never; form.errorsPerAttribute = { foo: ['Required'] }; + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); (form as unknown as { setErrorsForFields:(fields:string[]) => void; }).setErrorsForFields(['foo']); - await Promise.resolve(); - await Promise.resolve(); + await vi.waitFor(() => { + expect(activate).toHaveBeenCalledTimes(1); + }); expect(requireVisible).toHaveBeenCalledTimes(1); + expect(activate).toHaveBeenCalledWith('foo', true); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); }); 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 776bcc6d2cf..4ad4b42b194 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 @@ -155,7 +155,7 @@ describe('DynamicIconDirective', () => { }); it('should warn when rendering unknown icon', () => { - vi.spyOn(console, 'warn'); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); component.iconName = 'unknown-icon'; fixture.detectChanges(); @@ -164,7 +164,7 @@ describe('DynamicIconDirective', () => { }); it('should not render anything for unknown icon', () => { - vi.spyOn(console, 'warn'); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); component.iconName = 'unknown-icon'; fixture.detectChanges(); @@ -180,7 +180,7 @@ describe('DynamicIconDirective', () => { }); it('should handle empty icon name', () => { - vi.spyOn(console, 'warn'); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); component.iconName = ''; fixture.detectChanges(); @@ -195,7 +195,7 @@ describe('DynamicIconDirective', () => { }); it('should only render once when loaded', () => { - vi.spyOn(console, 'warn'); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); const directive = fixture.debugElement.children[0].injector.get(DynamicIconDirective); vi.spyOn(directive as any, 'renderIcon'); diff --git a/frontend/vitest-base.config.ts b/frontend/vitest-base.config.ts new file mode 100644 index 00000000000..beea733f225 --- /dev/null +++ b/frontend/vitest-base.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +// Loaded by @angular-builders/custom-esbuild:unit-test via the `runnerConfig` +// option in angular.json. The builder layers its browser, setup-file and +// reporter settings on top of this file through an internal plugin, so only +// runner-level options that the builder does not manage belong here. +export default defineConfig({ + test: { + // jquery-migrate prints this banner to stdout at import time. It is + // expected, carries no signal, and would otherwise appear once per worker. + // Filtering here (reporter level) keeps it out of the output without + // mutating the global `console`, which is the idiomatic Vitest mechanism. + onConsoleLog(log:string):boolean | void { + if (log.includes('JQMIGRATE: Migrate is installed')) { + return false; + } + + return undefined; + }, + }, +});