Silence frontend spec warnings

Filter the jquery-migrate banner through Vitest `onConsoleLog` in a new
`vitest-base.config.ts` wired in via the builder `runnerConfig`, instead
of reassigning the global `console.log`. Console warnings and errors
that a spec asserts on are scoped per spec with `vi.spyOn`.

Drop the redundant manual `ngAfterViewInit` calls and select items
programmatically in the autocompleter spec, and wait on activation with
`vi.waitFor` in the edit-form spec instead of fixed microtask ticks.
This commit is contained in:
Alexander Brandon Coles
2026-06-13 15:13:50 +01:00
parent 7314e9a10c
commit f133e8fd80
5 changed files with 69 additions and 25 deletions
+1
View File
@@ -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"],
@@ -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<HTMLElement>('.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<HTMLElement>('.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({
@@ -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<HalResource> {
constructor(injector:Injector, private readonly requireVisibleSpy:(fieldName:string) => Promise<void>, private readonly activateFieldSpy:() => Promise<EditFieldHandler>, private readonly resetSpy:(fieldName:string, focus?:boolean) => void) {
constructor(injector:Injector, private readonly requireVisibleSpy:(fieldName:string) => Promise<void>, private readonly resetSpy:(fieldName:string, focus?:boolean) => void) {
super(injector);
}
@@ -41,8 +42,10 @@ class TestEditForm extends EditForm<HalResource> {
return this.requireVisibleSpy(fieldName);
}
public activateField():Promise<EditFieldHandler> {
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<EditFieldHandler> {
return Promise.resolve({} as EditFieldHandler);
}
public reset(fieldName:string, focus?:boolean):void {
@@ -55,10 +58,13 @@ class TestEditForm extends EditForm<HalResource> {
}
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();
});
});
@@ -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');
+21
View File
@@ -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;
},
},
});