From caad0c27d2f5f6e72c01d1d57a23fa7d67c8224f Mon Sep 17 00:00:00 2001 From: "S. Mumenthaler" Date: Sat, 20 Jul 2024 18:40:29 +0200 Subject: [PATCH 01/42] feat: introduce `on` as a improvement to subscribe to outputs (#465) Closes #462 --- projects/testing-library/src/lib/models.ts | 38 ++++- .../src/lib/testing-library.ts | 146 ++++++++++++------ projects/testing-library/tests/render.spec.ts | 132 +++++++++++++++- 3 files changed, 261 insertions(+), 55 deletions(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index fad3394..3cf053a 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,8 +1,16 @@ -import { Type, DebugElement } from '@angular/core'; -import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing'; +import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core'; +import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; +export type OutputRefKeysWithCallback = { + [key in keyof T]?: T[key] extends EventEmitter + ? (val: U) => void + : T[key] extends OutputRef + ? (val: U) => void + : never; +}; + export type RenderResultQueries = { [P in keyof Q]: BoundFunction }; export interface RenderResult extends RenderResultQueries { /** @@ -60,7 +68,7 @@ export interface RenderResult extend rerender: ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => Promise; /** @@ -205,12 +213,12 @@ export interface RenderComponentOptions { ... } + * const sendValue = new EventEmitter(); * await render(AppComponent, { * componentOutputs: { * send: { @@ -220,6 +228,24 @@ export interface RenderComponentOptions; + + /** + * @description + * An object with callbacks to subscribe to EventEmitters/Observables of the component + * + * @default + * {} + * + * @example + * const sendValue = (value) => { ... } + * await render(AppComponent, { + * on: { + * send: (_v:any) => void + * } + * }) + */ + on?: OutputRefKeysWithCallback; + /** * @description * A collection of providers to inject dependencies of the component. @@ -379,7 +405,7 @@ export interface RenderComponentOptions { diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 9b57b50..0ceda24 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -5,6 +5,8 @@ import { isStandalone, NgZone, OnChanges, + OutputRef, + OutputRefSubscription, SimpleChange, SimpleChanges, Type, @@ -25,9 +27,17 @@ import { waitForOptions as dtlWaitForOptions, within as dtlWithin, } from '@testing-library/dom'; -import { ComponentOverride, RenderComponentOptions, RenderResult, RenderTemplateOptions } from './models'; +import { + ComponentOverride, + RenderComponentOptions, + RenderResult, + RenderTemplateOptions, + OutputRefKeysWithCallback, +} from './models'; import { getConfig } from './config'; +type SubscribedOutput = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription]; + const mountedFixtures = new Set>(); const safeInject = TestBed.inject || TestBed.get; @@ -57,6 +67,7 @@ export async function render( componentProperties = {}, componentInputs = {}, componentOutputs = {}, + on = {}, componentProviders = [], childComponentOverrides = [], componentImports: componentImports, @@ -165,7 +176,55 @@ export async function render( let detectChanges: () => void; - const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs); + let renderedPropKeys = Object.keys(componentProperties); + let renderedInputKeys = Object.keys(componentInputs); + let renderedOutputKeys = Object.keys(componentOutputs); + let subscribedOutputs: SubscribedOutput[] = []; + + const renderFixture = async ( + properties: Partial, + inputs: Partial, + outputs: Partial, + subscribeTo: OutputRefKeysWithCallback, + ): Promise> => { + const createdFixture: ComponentFixture = await createComponent(componentContainer); + setComponentProperties(createdFixture, properties); + setComponentInputs(createdFixture, inputs); + setComponentOutputs(createdFixture, outputs); + subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo); + + if (removeAngularAttributes) { + createdFixture.nativeElement.removeAttribute('ng-version'); + const idAttribute = createdFixture.nativeElement.getAttribute('id'); + if (idAttribute && idAttribute.startsWith('root')) { + createdFixture.nativeElement.removeAttribute('id'); + } + } + + mountedFixtures.add(createdFixture); + + let isAlive = true; + createdFixture.componentRef.onDestroy(() => (isAlive = false)); + + if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { + const changes = getChangesObj(null, componentProperties); + createdFixture.componentInstance.ngOnChanges(changes); + } + + detectChanges = () => { + if (isAlive) { + createdFixture.detectChanges(); + } + }; + + if (detectChangesOnRender) { + detectChanges(); + } + + return createdFixture; + }; + + const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on); if (deferBlockStates) { if (Array.isArray(deferBlockStates)) { @@ -177,13 +236,10 @@ export async function render( } } - let renderedPropKeys = Object.keys(componentProperties); - let renderedInputKeys = Object.keys(componentInputs); - let renderedOutputKeys = Object.keys(componentOutputs); const rerender = async ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => { const newComponentInputs = properties?.componentInputs ?? {}; @@ -205,6 +261,22 @@ export async function render( setComponentOutputs(fixture, newComponentOutputs); renderedOutputKeys = Object.keys(newComponentOutputs); + // first unsubscribe the no longer available or changed callback-fns + const newObservableSubscriptions: OutputRefKeysWithCallback = properties?.on ?? {}; + for (const [key, cb, subscription] of subscribedOutputs) { + // when no longer provided or when the callback has changed + if (!(key in newObservableSubscriptions) || cb !== (newObservableSubscriptions as any)[key]) { + subscription.unsubscribe(); + } + } + // then subscribe the new callback-fns + subscribedOutputs = Object.entries(newObservableSubscriptions).map(([key, cb]) => { + const existing = subscribedOutputs.find(([k]) => k === key); + return existing && existing[1] === cb + ? existing // nothing to do + : subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void); + }); + const newComponentProps = properties?.componentProperties ?? {}; const changesInComponentProps = update( fixture, @@ -249,47 +321,6 @@ export async function render( : console.log(dtlPrettyDOM(element, maxLength, options)), ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; - - async function renderFixture( - properties: Partial, - inputs: Partial, - outputs: Partial, - ): Promise> { - const createdFixture = await createComponent(componentContainer); - setComponentProperties(createdFixture, properties); - setComponentInputs(createdFixture, inputs); - setComponentOutputs(createdFixture, outputs); - - if (removeAngularAttributes) { - createdFixture.nativeElement.removeAttribute('ng-version'); - const idAttribute = createdFixture.nativeElement.getAttribute('id'); - if (idAttribute && idAttribute.startsWith('root')) { - createdFixture.nativeElement.removeAttribute('id'); - } - } - - mountedFixtures.add(createdFixture); - - let isAlive = true; - createdFixture.componentRef.onDestroy(() => (isAlive = false)); - - if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { - const changes = getChangesObj(null, componentProperties); - createdFixture.componentInstance.ngOnChanges(changes); - } - - detectChanges = () => { - if (isAlive) { - createdFixture.detectChanges(); - } - }; - - if (detectChangesOnRender) { - detectChanges(); - } - - return createdFixture; - } } async function createComponent(component: Type): Promise> { @@ -355,6 +386,27 @@ function setComponentInputs( } } +function subscribeToComponentOutputs( + fixture: ComponentFixture, + listeners: OutputRefKeysWithCallback, +): SubscribedOutput[] { + // with Object.entries we lose the type information of the key and callback, therefore we need to cast them + return Object.entries(listeners).map(([key, cb]) => + subscribeToComponentOutput(fixture, key as keyof SutType, cb as (v: any) => void), + ); +} + +function subscribeToComponentOutput( + fixture: ComponentFixture, + key: keyof SutType, + cb: (val: any) => void, +): SubscribedOutput { + const eventEmitter = (fixture.componentInstance as any)[key] as OutputRef; + const subscription = eventEmitter.subscribe(cb); + fixture.componentRef.onDestroy(subscription.unsubscribe.bind(subscription)); + return [key, cb, subscription]; +} + function overrideComponentImports(sut: Type | string, imports: (Type | any[])[] | undefined) { if (imports) { if (typeof sut === 'function' && isStandalone(sut)) { diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 56f4608..b73c9c7 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -10,12 +10,16 @@ import { Injectable, EventEmitter, Output, + ElementRef, + inject, + output, } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestBed } from '@angular/core/testing'; -import { render, fireEvent, screen } from '../src/public_api'; +import { render, fireEvent, screen, OutputRefKeysWithCallback } from '../src/public_api'; import { ActivatedRoute, Resolve, RouterModule } from '@angular/router'; -import { map } from 'rxjs'; +import { fromEvent, map } from 'rxjs'; import { AsyncPipe, NgIf } from '@angular/common'; @Component({ @@ -183,6 +187,130 @@ describe('componentOutputs', () => { }); }); +describe('on', () => { + @Component({ template: ``, standalone: true }) + class TestFixtureWithEventEmitterComponent { + @Output() readonly event = new EventEmitter(); + } + + @Component({ template: ``, standalone: true }) + class TestFixtureWithDerivedEventComponent { + @Output() readonly event = fromEvent(inject(ElementRef).nativeElement, 'click'); + } + + @Component({ template: ``, standalone: true }) + class TestFixtureWithFunctionalOutputComponent { + readonly event = output(); + } + + @Component({ template: ``, standalone: true }) + class TestFixtureWithFunctionalDerivedEventComponent { + readonly event = outputFromObservable(fromEvent(inject(ElementRef).nativeElement, 'click')); + } + + it('should subscribe passed listener to the component EventEmitter', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithEventEmitterComponent, { on: { event: spy } }); + fixture.componentInstance.event.emit(); + expect(spy).toHaveBeenCalled(); + }); + + it('should unsubscribe on rerender without listener', async () => { + const spy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: spy }, + }); + + await rerender({}); + + fixture.componentInstance.event.emit(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not unsubscribe when same listener function is used on rerender', async () => { + const spy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: spy }, + }); + + await rerender({ on: { event: spy } }); + + fixture.componentInstance.event.emit(); + expect(spy).toHaveBeenCalled(); + }); + + it('should unsubscribe old and subscribe new listener function on rerender', async () => { + const firstSpy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: firstSpy }, + }); + + const newSpy = jest.fn(); + await rerender({ on: { event: newSpy } }); + + fixture.componentInstance.event.emit(); + + expect(firstSpy).not.toHaveBeenCalled(); + expect(newSpy).toHaveBeenCalled(); + }); + + it('should subscribe passed listener to a derived component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithDerivedEventComponent, { + on: { event: spy }, + }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); + + it('should subscribe passed listener to a functional component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalOutputComponent, { + on: { event: spy }, + }); + fixture.componentInstance.event.emit('test'); + expect(spy).toHaveBeenCalledWith('test'); + }); + + it('should subscribe passed listener to a functional derived component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalDerivedEventComponent, { + on: { event: spy }, + }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); + + it('OutputRefKeysWithCallback is correctly typed', () => { + const fnWithVoidArg = (_: void) => void 0; + const fnWithNumberArg = (_: number) => void 0; + const fnWithStringArg = (_: string) => void 0; + const fnWithMouseEventArg = (_: MouseEvent) => void 0; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + function _test(_on: OutputRefKeysWithCallback) {} + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithVoidArg }); + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithMouseEventArg }); + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithStringArg }); + + // @ts-expect-error + _test({ event: fnWithNumberArg }); + _test({ event: fnWithMouseEventArg }); + + // add a statement so the test succeeds + expect(true).toBeTruthy(); + }); +}); + describe('animationModule', () => { @NgModule({ declarations: [FixtureComponent], From 679c5f9b42437deda42b00ea9a69f1a95973b2af Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 20 Jul 2024 18:41:56 +0200 Subject: [PATCH 02/42] docs: add mumenthalers as a contributor for code, and test (#466) --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 54c827e..c1c0709 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -370,6 +370,16 @@ "code", "test" ] + }, + { + "login": "mumenthalers", + "name": "S. Mumenthaler", + "avatar_url": "https://avatars.githubusercontent.com/u/3604424?v=4", + "profile": "https://github.com/mumenthalers", + "contributions": [ + "code", + "test" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index ce2e906..883c9dc 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ Thanks goes to these people ([emoji key][emojis]): Florian Pabst
Florian Pabst

💻 Mark Goho
Mark Goho

🚧 📖 Jan-Willem Baart
Jan-Willem Baart

💻 ⚠️ + S. Mumenthaler
S. Mumenthaler

💻 ⚠️ From 40fe4ea066e9253f2382588caecda1aae8fc84d0 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Tue, 23 Jul 2024 20:43:23 +0200 Subject: [PATCH 03/42] docs: use on in docs (#469) Closes #468 --- .../src/app/examples/02-input-output.spec.ts | 58 ++++++++++++++++++- .../22-signal-inputs.component.spec.ts | 4 +- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/example-app/src/app/examples/02-input-output.spec.ts b/apps/example-app/src/app/examples/02-input-output.spec.ts index c193d3e..abc0066 100644 --- a/apps/example-app/src/app/examples/02-input-output.spec.ts +++ b/apps/example-app/src/app/examples/02-input-output.spec.ts @@ -7,6 +7,62 @@ test('is possible to set input and listen for output', async () => { const user = userEvent.setup(); const sendValue = jest.fn(); + await render(InputOutputComponent, { + componentInputs: { + value: 47, + }, + on: { + sendValue, + }, + }); + + const incrementControl = screen.getByRole('button', { name: /increment/i }); + const sendControl = screen.getByRole('button', { name: /send/i }); + const valueControl = screen.getByTestId('value'); + + expect(valueControl).toHaveTextContent('47'); + + await user.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); + expect(valueControl).toHaveTextContent('50'); + + await user.click(sendControl); + expect(sendValue).toHaveBeenCalledTimes(1); + expect(sendValue).toHaveBeenCalledWith(50); +}); + +test.skip('is possible to set input and listen for output with the template syntax', async () => { + const user = userEvent.setup(); + const sendSpy = jest.fn(); + + await render('', { + imports: [InputOutputComponent], + on: { + sendValue: sendSpy, + }, + }); + + const incrementControl = screen.getByRole('button', { name: /increment/i }); + const sendControl = screen.getByRole('button', { name: /send/i }); + const valueControl = screen.getByTestId('value'); + + expect(valueControl).toHaveTextContent('47'); + + await user.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); + expect(valueControl).toHaveTextContent('50'); + + await user.click(sendControl); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith(50); +}); + +test('is possible to set input and listen for output (deprecated)', async () => { + const user = userEvent.setup(); + const sendValue = jest.fn(); + await render(InputOutputComponent, { componentInputs: { value: 47, @@ -34,7 +90,7 @@ test('is possible to set input and listen for output', async () => { expect(sendValue).toHaveBeenCalledWith(50); }); -test('is possible to set input and listen for output with the template syntax', async () => { +test('is possible to set input and listen for output with the template syntax (deprecated)', async () => { const user = userEvent.setup(); const sendSpy = jest.fn(); diff --git a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts index 113d330..a05ea5b 100644 --- a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts @@ -55,8 +55,8 @@ test('output emits a value', async () => { greeting: 'Hello', name: 'world', }, - componentOutputs: { - submit: { emit: submitFn } as any, + on: { + submit: submitFn, }, }); From 3095737ef1f84e42586c716a1d15c67b7a59809f Mon Sep 17 00:00:00 2001 From: Andrei Alecu Date: Sat, 3 Aug 2024 20:19:25 +0300 Subject: [PATCH 04/42] feat: add strongly typed inputs (#473) Closes #464 Closes #474 --- README.md | 33 +++-- .../src/app/examples/02-input-output.spec.ts | 4 +- .../22-signal-inputs.component.spec.ts | 30 ++--- projects/testing-library/src/lib/models.ts | 44 ++++++- .../src/lib/testing-library.ts | 11 +- .../tests/integrations/ng-mocks.spec.ts | 4 +- projects/testing-library/tests/render.spec.ts | 118 +++++++++++++++++- .../testing-library/tests/rerender.spec.ts | 12 +- 8 files changed, 212 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 883c9dc..22f42f6 100644 --- a/README.md +++ b/README.md @@ -100,20 +100,22 @@ counter.component.ts @Component({ selector: 'app-counter', template: ` + {{ hello() }} - Current Count: {{ counter }} + Current Count: {{ counter() }} `, }) export class CounterComponent { - @Input() counter = 0; + counter = model(0); + hello = input('Hi', { alias: 'greeting' }); increment() { - this.counter += 1; + this.counter.set(this.counter() + 1); } decrement() { - this.counter -= 1; + this.counter.set(this.counter() - 1); } } ``` @@ -121,23 +123,30 @@ export class CounterComponent { counter.component.spec.ts ```typescript -import { render, screen, fireEvent } from '@testing-library/angular'; +import { render, screen, fireEvent, aliasedInput } from '@testing-library/angular'; import { CounterComponent } from './counter.component'; describe('Counter', () => { - test('should render counter', async () => { - await render(CounterComponent, { componentProperties: { counter: 5 } }); - - expect(screen.getByText('Current Count: 5')); + it('should render counter', async () => { + await render(CounterComponent, { + inputs: { + counter: 5, + // aliases need to be specified this way + ...aliasedInput('greeting', 'Hello Alias!'), + }, + }); + + expect(screen.getByText('Current Count: 5')).toBeVisible(); + expect(screen.getByText('Hello Alias!')).toBeVisible(); }); - test('should increment the counter on click', async () => { - await render(CounterComponent, { componentProperties: { counter: 5 } }); + it('should increment the counter on click', async () => { + await render(CounterComponent, { inputs: { counter: 5 } }); const incrementButton = screen.getByRole('button', { name: '+' }); fireEvent.click(incrementButton); - expect(screen.getByText('Current Count: 6')); + expect(screen.getByText('Current Count: 6')).toBeVisible(); }); }); ``` diff --git a/apps/example-app/src/app/examples/02-input-output.spec.ts b/apps/example-app/src/app/examples/02-input-output.spec.ts index abc0066..847f6e1 100644 --- a/apps/example-app/src/app/examples/02-input-output.spec.ts +++ b/apps/example-app/src/app/examples/02-input-output.spec.ts @@ -8,7 +8,7 @@ test('is possible to set input and listen for output', async () => { const sendValue = jest.fn(); await render(InputOutputComponent, { - componentInputs: { + inputs: { value: 47, }, on: { @@ -64,7 +64,7 @@ test('is possible to set input and listen for output (deprecated)', async () => const sendValue = jest.fn(); await render(InputOutputComponent, { - componentInputs: { + inputs: { value: 47, }, componentOutputs: { diff --git a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts index a05ea5b..cb22ba6 100644 --- a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts @@ -1,11 +1,11 @@ -import { render, screen, within } from '@testing-library/angular'; +import { aliasedInput, render, screen, within } from '@testing-library/angular'; import { SignalInputComponent } from './22-signal-inputs.component'; import userEvent from '@testing-library/user-event'; test('works with signal inputs', async () => { await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + inputs: { + ...aliasedInput('greeting', 'Hello'), name: 'world', }, }); @@ -16,8 +16,8 @@ test('works with signal inputs', async () => { test('works with computed', async () => { await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + inputs: { + ...aliasedInput('greeting', 'Hello'), name: 'world', }, }); @@ -28,8 +28,8 @@ test('works with computed', async () => { test('can update signal inputs', async () => { const { fixture } = await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + inputs: { + ...aliasedInput('greeting', 'Hello'), name: 'world', }, }); @@ -51,8 +51,8 @@ test('can update signal inputs', async () => { test('output emits a value', async () => { const submitFn = jest.fn(); await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + inputs: { + ...aliasedInput('greeting', 'Hello'), name: 'world', }, on: { @@ -67,8 +67,8 @@ test('output emits a value', async () => { test('model update also updates the template', async () => { const { fixture } = await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + inputs: { + ...aliasedInput('greeting', 'Hello'), name: 'initial', }, }); @@ -97,8 +97,8 @@ test('model update also updates the template', async () => { test('works with signal inputs, computed values, and rerenders', async () => { const view = await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + inputs: { + ...aliasedInput('greeting', 'Hello'), name: 'world', }, }); @@ -110,8 +110,8 @@ test('works with signal inputs, computed values, and rerenders', async () => { expect(computedValue.getByText(/hello world/i)).toBeInTheDocument(); await view.rerender({ - componentInputs: { - greeting: 'bye', + inputs: { + ...aliasedInput('greeting', 'bye'), name: 'test', }, }); diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 3cf053a..8e0e57f 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,4 +1,4 @@ -import { Type, DebugElement, OutputRef, EventEmitter } from '@angular/core'; +import { Type, DebugElement, OutputRef, EventEmitter, Signal } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; @@ -68,7 +68,7 @@ export interface RenderResult extend rerender: ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => Promise; /** @@ -78,6 +78,27 @@ export interface RenderResult extend renderDeferBlock: (deferBlockState: DeferBlockState, deferBlockIndex?: number) => Promise; } +declare const ALIASED_INPUT_BRAND: unique symbol; +export type AliasedInput = T & { + [ALIASED_INPUT_BRAND]: T; +}; +export type AliasedInputs = Record>; + +export type ComponentInput = + | { + [P in keyof T]?: T[P] extends Signal ? U : T[P]; + } + | AliasedInputs; + +/** + * @description + * Creates an aliased input branded type with a value + * + */ +export function aliasedInput(alias: TAlias, value: T): Record> { + return { [alias]: value } as Record>; +} + export interface RenderComponentOptions { /** * @description @@ -199,6 +220,7 @@ export interface RenderComponentOptions | { [alias: string]: unknown }; + + /** + * @description + * An object to set `@Input` or `input()` properties of the component + * + * @default + * {} + * + * @example + * await render(AppComponent, { + * inputs: { + * counterValue: 10, + * // explicitly define aliases this way: + * ...aliasedInput('someAlias', 'someValue') + * }) + */ + inputs?: ComponentInput; + /** * @description * An object to set `@Output` properties of the component diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 0ceda24..fbe94f2 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -67,6 +67,7 @@ export async function render( componentProperties = {}, componentInputs = {}, componentOutputs = {}, + inputs: newInputs = {}, on = {}, componentProviders = [], childComponentOverrides = [], @@ -176,8 +177,10 @@ export async function render( let detectChanges: () => void; + const allInputs = { ...componentInputs, ...newInputs }; + let renderedPropKeys = Object.keys(componentProperties); - let renderedInputKeys = Object.keys(componentInputs); + let renderedInputKeys = Object.keys(allInputs); let renderedOutputKeys = Object.keys(componentOutputs); let subscribedOutputs: SubscribedOutput[] = []; @@ -224,7 +227,7 @@ export async function render( return createdFixture; }; - const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs, on); + const fixture = await renderFixture(componentProperties, allInputs, componentOutputs, on); if (deferBlockStates) { if (Array.isArray(deferBlockStates)) { @@ -239,10 +242,10 @@ export async function render( const rerender = async ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => { - const newComponentInputs = properties?.componentInputs ?? {}; + const newComponentInputs = { ...properties?.componentInputs, ...properties?.inputs }; const changesInComponentInput = update( fixture, renderedInputKeys, diff --git a/projects/testing-library/tests/integrations/ng-mocks.spec.ts b/projects/testing-library/tests/integrations/ng-mocks.spec.ts index 6358485..8886fb3 100644 --- a/projects/testing-library/tests/integrations/ng-mocks.spec.ts +++ b/projects/testing-library/tests/integrations/ng-mocks.spec.ts @@ -8,7 +8,7 @@ import { NgIf } from '@angular/common'; test('sends the correct value to the child input', async () => { const utils = await render(TargetComponent, { imports: [MockComponent(ChildComponent)], - componentInputs: { value: 'foo' }, + inputs: { value: 'foo' }, }); const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent)); @@ -21,7 +21,7 @@ test('sends the correct value to the child input', async () => { test('sends the correct value to the child input 2', async () => { const utils = await render(TargetComponent, { imports: [MockComponent(ChildComponent)], - componentInputs: { value: 'bar' }, + inputs: { value: 'bar' }, }); const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent)); diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index b73c9c7..59e0f75 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -13,11 +13,13 @@ import { ElementRef, inject, output, + input, + model, } from '@angular/core'; import { outputFromObservable } from '@angular/core/rxjs-interop'; import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestBed } from '@angular/core/testing'; -import { render, fireEvent, screen, OutputRefKeysWithCallback } from '../src/public_api'; +import { render, fireEvent, screen, OutputRefKeysWithCallback, aliasedInput } from '../src/public_api'; import { ActivatedRoute, Resolve, RouterModule } from '@angular/router'; import { fromEvent, map } from 'rxjs'; import { AsyncPipe, NgIf } from '@angular/common'; @@ -533,3 +535,117 @@ describe('configureTestBed', () => { expect(configureTestBedFn).toHaveBeenCalledTimes(1); }); }); + +describe('inputs and signals', () => { + @Component({ + selector: 'atl-fixture', + template: `{{ myName() }} {{ myJob() }}`, + }) + class InputComponent { + myName = input('foo'); + + myJob = input('bar', { alias: 'job' }); + } + + it('should set the input component', async () => { + await render(InputComponent, { + inputs: { + myName: 'Bob', + ...aliasedInput('job', 'Builder'), + }, + }); + + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Builder')).toBeInTheDocument(); + }); + + it('should typecheck correctly', async () => { + // we only want to check the types here + // so we are purposely not calling render + + const typeTests = [ + async () => { + // OK: + await render(InputComponent, { + inputs: { + myName: 'OK', + }, + }); + }, + async () => { + // @ts-expect-error - myName is a string + await render(InputComponent, { + inputs: { + myName: 123, + }, + }); + }, + async () => { + // OK: + await render(InputComponent, { + inputs: { + ...aliasedInput('job', 'OK'), + }, + }); + }, + async () => { + // @ts-expect-error - job is not using aliasedInput + await render(InputComponent, { + inputs: { + job: 'not used with aliasedInput', + }, + }); + }, + ]; + + // add a statement so the test succeeds + expect(typeTests).toBeTruthy(); + }); +}); + +describe('README examples', () => { + describe('Counter', () => { + @Component({ + selector: 'atl-counter', + template: ` + {{ hello() }} + + Current Count: {{ counter() }} + + `, + }) + class CounterComponent { + counter = model(0); + hello = input('Hi', { alias: 'greeting' }); + + increment() { + this.counter.set(this.counter() + 1); + } + + decrement() { + this.counter.set(this.counter() - 1); + } + } + + it('should render counter', async () => { + await render(CounterComponent, { + inputs: { + counter: 5, + ...aliasedInput('greeting', 'Hello Alias!'), + }, + }); + + expect(screen.getByText('Current Count: 5')).toBeVisible(); + expect(screen.getByText('Hello Alias!')).toBeVisible(); + }); + + it('should increment the counter on click', async () => { + await render(CounterComponent, { inputs: { counter: 5 } }); + + const incrementButton = screen.getByRole('button', { name: '+' }); + fireEvent.click(incrementButton); + + expect(screen.getByText('Current Count: 6')).toBeVisible(); + }); + }); +}); diff --git a/projects/testing-library/tests/rerender.spec.ts b/projects/testing-library/tests/rerender.spec.ts index 571d642..04b8185 100644 --- a/projects/testing-library/tests/rerender.spec.ts +++ b/projects/testing-library/tests/rerender.spec.ts @@ -43,7 +43,7 @@ test('rerenders the component with updated inputs', async () => { expect(screen.getByText('Sarah')).toBeInTheDocument(); const firstName = 'Mark'; - await rerender({ componentInputs: { firstName } }); + await rerender({ inputs: { firstName } }); expect(screen.getByText(firstName)).toBeInTheDocument(); }); @@ -52,7 +52,7 @@ test('rerenders the component with updated inputs and resets other props', async const firstName = 'Mark'; const lastName = 'Peeters'; const { rerender } = await render(FixtureComponent, { - componentInputs: { + inputs: { firstName, lastName, }, @@ -61,7 +61,7 @@ test('rerenders the component with updated inputs and resets other props', async expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); const firstName2 = 'Chris'; - await rerender({ componentInputs: { firstName: firstName2 } }); + await rerender({ inputs: { firstName: firstName2 } }); expect(screen.getByText(firstName2)).toBeInTheDocument(); expect(screen.queryByText(firstName)).not.toBeInTheDocument(); @@ -87,7 +87,7 @@ test('rerenders the component with updated inputs and keeps other props when par const firstName = 'Mark'; const lastName = 'Peeters'; const { rerender } = await render(FixtureComponent, { - componentInputs: { + inputs: { firstName, lastName, }, @@ -96,7 +96,7 @@ test('rerenders the component with updated inputs and keeps other props when par expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); const firstName2 = 'Chris'; - await rerender({ componentInputs: { firstName: firstName2 }, partialUpdate: true }); + await rerender({ inputs: { firstName: firstName2 }, partialUpdate: true }); expect(screen.queryByText(firstName)).not.toBeInTheDocument(); expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument(); @@ -181,7 +181,7 @@ test('change detection gets not called if `detectChangesOnRender` is set to fals expect(screen.getByText('Sarah')).toBeInTheDocument(); const firstName = 'Mark'; - await rerender({ componentInputs: { firstName }, detectChangesOnRender: false }); + await rerender({ inputs: { firstName }, detectChangesOnRender: false }); expect(screen.getByText('Sarah')).toBeInTheDocument(); expect(screen.queryByText(firstName)).not.toBeInTheDocument(); From 80ec75d94f08f7c535246c513e6ab7ae66b24c28 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 3 Aug 2024 19:20:46 +0200 Subject: [PATCH 05/42] docs: add andreialecu as a contributor for code, ideas, and doc (#475) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 11 +++++++++++ README.md | 1 + 2 files changed, 12 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index c1c0709..f79ae1c 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -380,6 +380,17 @@ "code", "test" ] + }, + { + "login": "andreialecu", + "name": "Andrei Alecu", + "avatar_url": "https://avatars.githubusercontent.com/u/697707?v=4", + "profile": "https://lets.poker/", + "contributions": [ + "code", + "ideas", + "doc" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 22f42f6..dc11c45 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ Thanks goes to these people ([emoji key][emojis]): Mark Goho
Mark Goho

🚧 📖 Jan-Willem Baart
Jan-Willem Baart

💻 ⚠️ S. Mumenthaler
S. Mumenthaler

💻 ⚠️ + Andrei Alecu
Andrei Alecu

💻 🤔 📖 From 641c65f5925b04b0cd03c66c32d5d111a543084b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Ram=C3=ADrez=20Barrientos?= Date: Tue, 6 Aug 2024 10:58:47 -0600 Subject: [PATCH 06/42] feat: prompt for jest-dom and user-event installation in ng-add (#478) Closes #477 Closes #472 --- .../schematics/ng-add/index.ts | 35 ++++++++++++++----- .../schematics/ng-add/schema.json | 24 ++++++++++++- .../schematics/ng-add/schema.ts | 5 ++- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/projects/testing-library/schematics/ng-add/index.ts b/projects/testing-library/schematics/ng-add/index.ts index 24a0a3d..d961e15 100644 --- a/projects/testing-library/schematics/ng-add/index.ts +++ b/projects/testing-library/schematics/ng-add/index.ts @@ -1,27 +1,44 @@ -import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { chain, noop, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { addPackageJsonDependency, getPackageJsonDependency, NodeDependencyType, } from '@schematics/angular/utility/dependencies'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import { Schema } from './schema'; -const dtl = '@testing-library/dom'; +export default function ({ installJestDom, installUserEvent }: Schema): Rule { + return () => { + return chain([ + addDependency('@testing-library/dom', '^10.0.0', NodeDependencyType.Dev), + installJestDom ? addDependency('@testing-library/jest-dom', '^6.4.8', NodeDependencyType.Dev) : noop(), + installUserEvent ? addDependency('@testing-library/user-event', '^14.5.2', NodeDependencyType.Dev) : noop(), + installDependencies(), + ]); + }; +} -export default function (): Rule { +function addDependency(packageName: string, version: string, dependencyType: NodeDependencyType) { return (tree: Tree, context: SchematicContext) => { - const dtlDep = getPackageJsonDependency(tree, dtl); + const dtlDep = getPackageJsonDependency(tree, packageName); if (dtlDep) { - context.logger.info(`Skipping installation of '@testing-library/dom' because it's already installed.`); + context.logger.info(`Skipping installation of '${packageName}' because it's already installed.`); } else { - context.logger.info(`Adding '@testing-library/dom' as a dev dependency.`); - addPackageJsonDependency(tree, { name: dtl, type: NodeDependencyType.Dev, overwrite: false, version: '^10.0.0' }); + context.logger.info(`Adding '${packageName}' as a dev dependency.`); + addPackageJsonDependency(tree, { name: packageName, type: dependencyType, overwrite: false, version }); } + return tree; + }; +} + +export function installDependencies(packageManager = 'npm') { + return (_tree: Tree, context: SchematicContext) => { + context.addTask(new NodePackageInstallTask({ packageManager })); + context.logger.info( `Correctly installed @testing-library/angular. See our docs at https://testing-library.com/docs/angular-testing-library/intro/ to get started.`, ); - - return tree; }; } diff --git a/projects/testing-library/schematics/ng-add/schema.json b/projects/testing-library/schematics/ng-add/schema.json index 3f35a9a..30cc97d 100644 --- a/projects/testing-library/schematics/ng-add/schema.json +++ b/projects/testing-library/schematics/ng-add/schema.json @@ -3,6 +3,28 @@ "$id": "SchematicsTestingLibraryAngular", "title": "testing-library-angular", "type": "object", - "properties": {}, + "properties": { + "installJestDom": { + "type": "boolean", + "description": "Install jest-dom as a dependency.", + "$default": { + "$source": "argv", + "index": 0 + }, + "default": false, + "x-prompt": "Would you like to install jest-dom?" + }, + "installUserEvent": { + "type": "boolean", + "description": "Install user-event as a dependency.", + "$default": { + "$source": "argv", + "index": 1 + }, + "default": false, + "x-prompt": "Would you like to install user-event?" + } + }, + "additionalProperties": false, "required": [] } diff --git a/projects/testing-library/schematics/ng-add/schema.ts b/projects/testing-library/schematics/ng-add/schema.ts index 02bea61..dc14633 100644 --- a/projects/testing-library/schematics/ng-add/schema.ts +++ b/projects/testing-library/schematics/ng-add/schema.ts @@ -1,2 +1,5 @@ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface Schema {} +export interface Schema { + installJestDom: boolean; + installUserEvent: boolean; +} From 5c5732da37c09e49d15051f80676611df3af3a7a Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 12:18:34 +0200 Subject: [PATCH 07/42] docs: add Hyperxq as a contributor for code (#479) --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index f79ae1c..a919fb6 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -391,6 +391,15 @@ "ideas", "doc" ] + }, + { + "login": "Hyperxq", + "name": "Daniel Ramírez Barrientos", + "avatar_url": "https://avatars.githubusercontent.com/u/22332354?v=4", + "profile": "https://github.com/Hyperxq", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index dc11c45..f542b69 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,7 @@ Thanks goes to these people ([emoji key][emojis]): Jan-Willem Baart
Jan-Willem Baart

💻 ⚠️ S. Mumenthaler
S. Mumenthaler

💻 ⚠️ Andrei Alecu
Andrei Alecu

💻 🤔 📖 + Daniel Ramírez Barrientos
Daniel Ramírez Barrientos

💻 From 8ca97c7762f73dd01bf4f792987fb8cb4756039c Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Wed, 7 Aug 2024 19:06:48 +0200 Subject: [PATCH 08/42] fix: do not import OutputRef (#481) Closes #480 --- projects/testing-library/src/lib/models.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 8e0e57f..f7697c5 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,8 +1,16 @@ -import { Type, DebugElement, OutputRef, EventEmitter, Signal } from '@angular/core'; +import { Type, DebugElement, EventEmitter, Signal } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; +// TODO: import from Angular (is a breaking change) +interface OutputRef { + subscribe(callback: (value: T) => void): OutputRefSubscription; +} +interface OutputRefSubscription { + unsubscribe(): void; +} + export type OutputRefKeysWithCallback = { [key in keyof T]?: T[key] extends EventEmitter ? (val: U) => void From 9160da11d4870ea7d4a59e2912bdf75c4022ebe3 Mon Sep 17 00:00:00 2001 From: Mahdi Lazraq <94069699+mlz11@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:48:52 +0200 Subject: [PATCH 09/42] fix: add support for InputSignalWithTransform in inputs property (#484) Closes #483 --- .../22-signal-inputs.component.spec.ts | 25 ++++++++++++------- .../examples/22-signal-inputs.component.ts | 7 +++--- projects/testing-library/src/lib/models.ts | 8 ++++-- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts index cb22ba6..470e639 100644 --- a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts @@ -7,11 +7,12 @@ test('works with signal inputs', async () => { inputs: { ...aliasedInput('greeting', 'Hello'), name: 'world', + age: '45', }, }); const inputValue = within(screen.getByTestId('input-value')); - expect(inputValue.getByText(/hello world/i)).toBeInTheDocument(); + expect(inputValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); }); test('works with computed', async () => { @@ -19,11 +20,12 @@ test('works with computed', async () => { inputs: { ...aliasedInput('greeting', 'Hello'), name: 'world', + age: '45', }, }); const computedValue = within(screen.getByTestId('computed-value')); - expect(computedValue.getByText(/hello world/i)).toBeInTheDocument(); + expect(computedValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); }); test('can update signal inputs', async () => { @@ -31,18 +33,19 @@ test('can update signal inputs', async () => { inputs: { ...aliasedInput('greeting', 'Hello'), name: 'world', + age: '45', }, }); const inputValue = within(screen.getByTestId('input-value')); const computedValue = within(screen.getByTestId('computed-value')); - expect(inputValue.getByText(/hello world/i)).toBeInTheDocument(); + expect(inputValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); fixture.componentInstance.name.set('updated'); // set doesn't trigger change detection within the test, findBy is needed to update the template - expect(await inputValue.findByText(/hello updated/i)).toBeInTheDocument(); - expect(await computedValue.findByText(/hello updated/i)).toBeInTheDocument(); + expect(await inputValue.findByText(/hello updated of 45 years old/i)).toBeInTheDocument(); + expect(await computedValue.findByText(/hello updated of 45 years old/i)).toBeInTheDocument(); // it's not recommended to access the model directly, but it's possible expect(fixture.componentInstance.name()).toBe('updated'); @@ -54,6 +57,7 @@ test('output emits a value', async () => { inputs: { ...aliasedInput('greeting', 'Hello'), name: 'world', + age: '45', }, on: { submit: submitFn, @@ -70,6 +74,7 @@ test('model update also updates the template', async () => { inputs: { ...aliasedInput('greeting', 'Hello'), name: 'initial', + age: '45', }, }); @@ -100,22 +105,24 @@ test('works with signal inputs, computed values, and rerenders', async () => { inputs: { ...aliasedInput('greeting', 'Hello'), name: 'world', + age: '45', }, }); const inputValue = within(screen.getByTestId('input-value')); const computedValue = within(screen.getByTestId('computed-value')); - expect(inputValue.getByText(/hello world/i)).toBeInTheDocument(); - expect(computedValue.getByText(/hello world/i)).toBeInTheDocument(); + expect(inputValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); + expect(computedValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); await view.rerender({ inputs: { ...aliasedInput('greeting', 'bye'), name: 'test', + age: '0', }, }); - expect(inputValue.getByText(/bye test/i)).toBeInTheDocument(); - expect(computedValue.getByText(/bye test/i)).toBeInTheDocument(); + expect(inputValue.getByText(/bye test of 0 years old/i)).toBeInTheDocument(); + expect(computedValue.getByText(/bye test of 0 years old/i)).toBeInTheDocument(); }); diff --git a/apps/example-app/src/app/examples/22-signal-inputs.component.ts b/apps/example-app/src/app/examples/22-signal-inputs.component.ts index ddc0c90..dfe6bd0 100644 --- a/apps/example-app/src/app/examples/22-signal-inputs.component.ts +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.ts @@ -1,10 +1,10 @@ -import { Component, computed, input, model, output } from '@angular/core'; +import { Component, computed, input, model, numberAttribute, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-signal-input', template: ` -
{{ greetings() }} {{ name() }}
+
{{ greetings() }} {{ name() }} of {{ age() }} years old
{{ greetingMessage() }}
@@ -16,10 +16,11 @@ export class SignalInputComponent { greetings = input('', { alias: 'greeting', }); + age = input.required({ transform: numberAttribute }); name = model.required(); submit = output(); - greetingMessage = computed(() => `${this.greetings()} ${this.name()}`); + greetingMessage = computed(() => `${this.greetings()} ${this.name()} of ${this.age()} years old`); submitName() { this.submit.emit(this.name()); diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index f7697c5..1956728 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,4 +1,4 @@ -import { Type, DebugElement, EventEmitter, Signal } from '@angular/core'; +import { Type, DebugElement, EventEmitter, Signal, InputSignalWithTransform } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; @@ -94,7 +94,11 @@ export type AliasedInputs = Record>; export type ComponentInput = | { - [P in keyof T]?: T[P] extends Signal ? U : T[P]; + [P in keyof T]?: T[P] extends InputSignalWithTransform + ? U + : T[P] extends Signal + ? U + : T[P]; } | AliasedInputs; From b11b6c3c60a02172ad5bf02e7d3b1d8bb5196f8f Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 13 Aug 2024 19:50:36 +0200 Subject: [PATCH 10/42] docs: add mlz11 as a contributor for code, and test (#485) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index a919fb6..198cd09 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -400,6 +400,16 @@ "contributions": [ "code" ] + }, + { + "login": "mlz11", + "name": "Mahdi Lazraq", + "avatar_url": "https://avatars.githubusercontent.com/u/94069699?v=4", + "profile": "https://github.com/mlz11", + "contributions": [ + "code", + "test" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index f542b69..17bffce 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ Thanks goes to these people ([emoji key][emojis]): S. Mumenthaler
S. Mumenthaler

💻 ⚠️ Andrei Alecu
Andrei Alecu

💻 🤔 📖 Daniel Ramírez Barrientos
Daniel Ramírez Barrientos

💻 + Mahdi Lazraq
Mahdi Lazraq

💻 ⚠️ From 460fb32da1158dc8adc26ca6acd052078cb747d4 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Tue, 13 Aug 2024 20:04:07 +0200 Subject: [PATCH 11/42] From 81b457b8e5ec8e15d8af92cfdfdb666e83f90046 Mon Sep 17 00:00:00 2001 From: Mahdi Lazraq <94069699+mlz11@users.noreply.github.com> Date: Wed, 14 Aug 2024 08:52:03 +0200 Subject: [PATCH 12/42] feat: deprecate componentProperties (#486) Closes #471 --- projects/testing-library/src/lib/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 1956728..62413fb 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -215,7 +215,7 @@ export interface RenderComponentOptions Date: Sat, 17 Aug 2024 10:51:10 +0200 Subject: [PATCH 13/42] chore: bump @angular-eslint/eslint-plugin v17.3.0 -> v18.3.0 (#488) --- .github/workflows/ci.yml | 2 +- package.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5820814..28aac0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: install - run: npm install --force + run: npm install - name: build run: npm run build -- --skip-nx-cache - name: test diff --git a/package.json b/package.json index 235d0ec..a934fb2 100644 --- a/package.json +++ b/package.json @@ -47,11 +47,11 @@ "@angular-devkit/build-angular": "18.0.1", "@angular-devkit/core": "18.0.1", "@angular-devkit/schematics": "18.0.1", - "@angular-eslint/builder": "17.3.0", - "@angular-eslint/eslint-plugin": "17.3.0", - "@angular-eslint/eslint-plugin-template": "17.3.0", - "@angular-eslint/schematics": "17.5.1", - "@angular-eslint/template-parser": "17.3.0", + "@angular-eslint/builder": "18.3.0", + "@angular-eslint/eslint-plugin": "18.3.0", + "@angular-eslint/eslint-plugin-template": "18.3.0", + "@angular-eslint/schematics": "18.3.0", + "@angular-eslint/template-parser": "18.3.0", "@angular/cli": "~18.0.0", "@angular/compiler-cli": "18.0.0", "@angular/forms": "18.0.0", From eb4fc7445b58af9b4fb6e0f0f5b886d4c0504065 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Fri, 23 Aug 2024 18:47:53 +0200 Subject: [PATCH 14/42] fix: update description in JSDocs (#489) --- projects/testing-library/src/lib/models.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 62413fb..a52371d 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -254,10 +254,11 @@ export interface RenderComponentOptions; @@ -292,7 +293,7 @@ export interface RenderComponentOptions { ... } * await render(AppComponent, { * on: { - * send: (_v:any) => void + * send: (value) => sendValue(value) * } * }) */ From fbbed20eaabd71db5e480af54da738b151482c80 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:26:57 +0200 Subject: [PATCH 15/42] docs: add test case for #492 (#495) --- .../tests/issues/issue-492.spec.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 projects/testing-library/tests/issues/issue-492.spec.ts diff --git a/projects/testing-library/tests/issues/issue-492.spec.ts b/projects/testing-library/tests/issues/issue-492.spec.ts new file mode 100644 index 0000000..981f5de --- /dev/null +++ b/projects/testing-library/tests/issues/issue-492.spec.ts @@ -0,0 +1,48 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, inject, Injectable } from '@angular/core'; +import { render, screen, waitFor } from '../../src/public_api'; +import { Observable, BehaviorSubject, map } from 'rxjs'; + +test('displays username', async () => { + // stubbed user service using a Subject + const user = new BehaviorSubject({ name: 'username 1' }); + const userServiceStub: Partial = { + getName: () => user.asObservable().pipe(map((u) => u.name)), + }; + + // render the component with injection of the stubbed service + await render(UserComponent, { + componentProviders: [ + { + provide: UserService, + useValue: userServiceStub, + }, + ], + }); + + // assert first username emitted is rendered + expect(await screen.findByRole('heading')).toHaveTextContent('username 1'); + + // emitting a second username + user.next({ name: 'username 2' }); + + // assert the second username is rendered + await waitFor(() => expect(screen.getByRole('heading')).toHaveTextContent('username 2')); +}); + +@Component({ + selector: 'atl-user', + standalone: true, + template: `

{{ username$ | async }}

`, + imports: [AsyncPipe], +}) +class UserComponent { + readonly username$: Observable = inject(UserService).getName(); +} + +@Injectable() +class UserService { + getName(): Observable { + throw new Error('Not implemented'); + } +} From be9c3d5cb990b98768f95bbc12e5ed33cbed72a6 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:30:37 +0200 Subject: [PATCH 16/42] docs: add test case for #492 --- projects/testing-library/tests/issues/issue-492.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/testing-library/tests/issues/issue-492.spec.ts b/projects/testing-library/tests/issues/issue-492.spec.ts index 981f5de..a1e44b0 100644 --- a/projects/testing-library/tests/issues/issue-492.spec.ts +++ b/projects/testing-library/tests/issues/issue-492.spec.ts @@ -1,6 +1,6 @@ import { AsyncPipe } from '@angular/common'; import { Component, inject, Injectable } from '@angular/core'; -import { render, screen, waitFor } from '../../src/public_api'; +import { render, screen } from '../../src/public_api'; import { Observable, BehaviorSubject, map } from 'rxjs'; test('displays username', async () => { @@ -21,13 +21,13 @@ test('displays username', async () => { }); // assert first username emitted is rendered - expect(await screen.findByRole('heading')).toHaveTextContent('username 1'); + expect(await screen.findByRole('heading', { name: 'username 1' })).toBeInTheDocument(); // emitting a second username user.next({ name: 'username 2' }); // assert the second username is rendered - await waitFor(() => expect(screen.getByRole('heading')).toHaveTextContent('username 2')); + expect(await screen.findByRole('heading', { name: 'username 2' })).toBeInTheDocument(); }); @Component({ From 2cfca82442cc2d51ba08e649f37b65fa285eadb6 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 14 Oct 2024 09:35:35 +0200 Subject: [PATCH 17/42] docs: add test case for #491 (#494) --- .../src/app/issues/issue-491.spec.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/example-app-karma/src/app/issues/issue-491.spec.ts diff --git a/apps/example-app-karma/src/app/issues/issue-491.spec.ts b/apps/example-app-karma/src/app/issues/issue-491.spec.ts new file mode 100644 index 0000000..7da4d6d --- /dev/null +++ b/apps/example-app-karma/src/app/issues/issue-491.spec.ts @@ -0,0 +1,57 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +it('test click event with router.navigate', async () => { + const user = userEvent.setup(); + await render(``, { + routes: [ + { + path: '', + component: LoginComponent, + }, + { + path: 'logged-in', + component: LoggedInComponent, + }, + ], + }); + + expect(await screen.findByRole('heading', { name: 'Login' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'submit' })).toBeInTheDocument(); + + const email = screen.getByRole('textbox', { name: 'email' }); + const password = screen.getByLabelText('password'); + + await user.type(email, 'user@example.com'); + await user.type(password, 'with_valid_password'); + + expect(screen.getByRole('button', { name: 'submit' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'submit' })); + + await waitForElementToBeRemoved(() => screen.queryByRole('heading', { name: 'Login' })); + + expect(await screen.findByRole('heading', { name: 'Logged In' })).toBeVisible(); +}); + +@Component({ + template: ` +

Login

+ + + + `, +}) +class LoginComponent { + constructor(private router: Router) {} + onSubmit(): void { + this.router.navigate(['logged-in']); + } +} + +@Component({ + template: `

Logged In

`, +}) +class LoggedInComponent {} From e8ddcf469a5c6dbadd6a1497cfa113ae56920461 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 14 Oct 2024 19:06:58 +0200 Subject: [PATCH 18/42] docs: add test case for #493 (#496) --- .../tests/issues/issue-493.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 projects/testing-library/tests/issues/issue-493.spec.ts diff --git a/projects/testing-library/tests/issues/issue-493.spec.ts b/projects/testing-library/tests/issues/issue-493.spec.ts new file mode 100644 index 0000000..a49bc80 --- /dev/null +++ b/projects/testing-library/tests/issues/issue-493.spec.ts @@ -0,0 +1,27 @@ +import { HttpClient, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Component, input } from '@angular/core'; +import { render, screen } from '../../src/public_api'; + +test('succeeds', async () => { + await render(DummyComponent, { + inputs: { + value: 'test', + }, + providers: [provideHttpClientTesting(), provideHttpClient()], + }); + + expect(screen.getByText('test')).toBeVisible(); +}); + +@Component({ + selector: 'atl-dummy', + standalone: true, + imports: [], + template: '

{{ value() }}

', +}) +class DummyComponent { + value = input.required(); + // @ts-ignore + constructor(private http: HttpClient) {} +} From 5a0665fad22a79af5837d0c110148a09ff963d11 Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Sun, 10 Nov 2024 01:58:38 +0900 Subject: [PATCH 19/42] fix: make wrapper component `standalone: false` explicitly (#498) --- projects/testing-library/src/lib/models.ts | 1 + projects/testing-library/src/lib/testing-library.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index a52371d..0c34aa8 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -473,6 +473,7 @@ export interface RenderTemplateOptions Date: Fri, 29 Nov 2024 19:52:25 +0100 Subject: [PATCH 20/42] chore: update to NX 20 (#502) --- .gitignore | 1 + .prettierignore | 3 +- jest.config.ts | 8 +++--- package.json | 73 +++++++++++++++++++++++++------------------------ 4 files changed, 44 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 215c8cb..d16a75b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ /.angular/cache .angular .nx +migrations.json .cache /.sass-cache /connect.lock diff --git a/.prettierignore b/.prettierignore index 2bdc4f9..03ff48d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -54,4 +54,5 @@ deployment.yaml .DS_Store Thumbs.db -/.nx/cache \ No newline at end of file +/.nx/cache +/.nx/workspace-data \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts index 0830aab..f5c10f4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,5 +1,5 @@ -const { getJestProjects } = require('@nx/jest'); +const { getJestProjectsAsync } = require('@nx/jest'); -export default { - projects: getJestProjects(), -}; +export default async () => ({ + projects: await getJestProjectsAsync(), +}); diff --git a/package.json b/package.json index a934fb2..e5030d0 100644 --- a/package.json +++ b/package.json @@ -27,51 +27,52 @@ "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "18.0.0", - "@angular/cdk": "18.0.0", - "@angular/common": "18.0.0", - "@angular/compiler": "18.0.0", - "@angular/core": "18.0.0", - "@angular/material": "18.0.0", - "@angular/platform-browser": "18.0.0", - "@angular/platform-browser-dynamic": "18.0.0", - "@angular/router": "18.0.0", - "@ngrx/store": "18.0.0-beta.1", - "@nx/angular": "19.1.0", + "@angular/animations": "18.2.13", + "@angular/cdk": "18.2.14", + "@angular/common": "18.2.13", + "@angular/compiler": "18.2.13", + "@angular/core": "18.2.13", + "@angular/material": "18.2.14", + "@angular/platform-browser": "18.2.13", + "@angular/platform-browser-dynamic": "18.2.13", + "@angular/router": "18.2.13", + "@ngrx/store": "18.0.2", + "@nx/angular": "20.1.3", "@testing-library/dom": "^10.0.0", "rxjs": "7.8.0", "tslib": "~2.3.1", - "zone.js": "0.14.2" + "zone.js": "0.14.10" }, "devDependencies": { - "@angular-devkit/build-angular": "18.0.1", - "@angular-devkit/core": "18.0.1", - "@angular-devkit/schematics": "18.0.1", + "@angular-devkit/build-angular": "18.2.9", + "@angular-devkit/core": "18.2.9", + "@angular-devkit/schematics": "18.2.9", "@angular-eslint/builder": "18.3.0", - "@angular-eslint/eslint-plugin": "18.3.0", - "@angular-eslint/eslint-plugin-template": "18.3.0", + "@angular-eslint/eslint-plugin": "18.0.1", + "@angular-eslint/eslint-plugin-template": "18.0.1", "@angular-eslint/schematics": "18.3.0", - "@angular-eslint/template-parser": "18.3.0", - "@angular/cli": "~18.0.0", - "@angular/compiler-cli": "18.0.0", - "@angular/forms": "18.0.0", - "@angular/language-service": "18.0.0", - "@nx/eslint": "19.1.0", - "@nx/eslint-plugin": "19.1.0", - "@nx/jest": "19.1.0", - "@nx/node": "19.1.0", - "@nx/plugin": "19.1.0", - "@nx/workspace": "19.1.0", - "@schematics/angular": "18.0.1", + "@angular-eslint/template-parser": "18.0.1", + "@angular/cli": "~18.2.0", + "@angular/compiler-cli": "18.2.13", + "@angular/forms": "18.2.13", + "@angular/language-service": "18.2.13", + "@nx/eslint": "20.1.3", + "@nx/eslint-plugin": "20.1.3", + "@nx/jest": "20.1.3", + "@nx/node": "20.1.3", + "@nx/plugin": "20.1.3", + "@nx/workspace": "20.1.3", + "@schematics/angular": "18.2.9", "@testing-library/jasmine-dom": "^1.2.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "^14.4.3", "@types/jasmine": "4.3.1", - "@types/jest": "29.5.1", + "@types/jest": "29.5.14", "@types/node": "18.16.9", "@types/testing-library__jasmine-dom": "^1.3.0", - "@typescript-eslint/eslint-plugin": "7.3.0", - "@typescript-eslint/parser": "7.3.0", + "@typescript-eslint/eslint-plugin": "7.16.0", + "@typescript-eslint/parser": "7.16.0", + "@typescript-eslint/utils": "^7.16.0", "autoprefixer": "^10.4.0", "cpy-cli": "^3.1.1", "eslint": "8.57.0", @@ -84,7 +85,7 @@ "jasmine-core": "4.2.0", "jasmine-spec-reporter": "7.0.0", "jest": "29.7.0", - "jest-environment-jsdom": "29.5.0", + "jest-environment-jsdom": "29.7.0", "jest-preset-angular": "14.1.0", "karma": "6.4.0", "karma-chrome-launcher": "^3.1.0", @@ -93,8 +94,8 @@ "karma-jasmine-html-reporter": "2.0.0", "lint-staged": "^12.1.6", "ng-mocks": "^14.11.0", - "ng-packagr": "18.0.0", - "nx": "19.1.0", + "ng-packagr": "18.2.1", + "nx": "20.1.3", "postcss": "^8.4.5", "postcss-import": "14.1.0", "postcss-preset-env": "7.5.0", @@ -104,6 +105,6 @@ "semantic-release": "^18.0.0", "ts-jest": "29.1.0", "ts-node": "10.9.1", - "typescript": "5.4.5" + "typescript": "5.5.4" } } From 061d5cc70aae3b2946707e1711ac833906f0aef5 Mon Sep 17 00:00:00 2001 From: Arthur Petrie Date: Sat, 30 Nov 2024 13:30:06 +0100 Subject: [PATCH 21/42] perf: optimize reduce and foreach loops (#501) --- .../src/lib/testing-library.ts | 73 ++++++++++--------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index ffd3898..8dfa946 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -2,7 +2,6 @@ import { ApplicationInitStatus, ChangeDetectorRef, Component, - isStandalone, NgZone, OnChanges, OutputRef, @@ -10,6 +9,7 @@ import { SimpleChange, SimpleChanges, Type, + isStandalone, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing'; import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -27,14 +27,14 @@ import { waitForOptions as dtlWaitForOptions, within as dtlWithin, } from '@testing-library/dom'; +import { getConfig } from './config'; import { ComponentOverride, + OutputRefKeysWithCallback, RenderComponentOptions, RenderResult, RenderTemplateOptions, - OutputRefKeysWithCallback, } from './models'; -import { getConfig } from './config'; type SubscribedOutput = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription]; @@ -71,7 +71,7 @@ export async function render( on = {}, componentProviders = [], childComponentOverrides = [], - componentImports: componentImports, + componentImports, excludeComponentDeclaration = false, routes = [], removeAngularAttributes = false, @@ -116,12 +116,9 @@ export async function render( await TestBed.compileComponents(); - componentProviders - .reduce((acc, provider) => acc.concat(provider), [] as any[]) - .forEach((p: any) => { - const { provide, ...provider } = p; - TestBed.overrideProvider(provide, provider); - }); + for (const { provide, ...provider } of componentProviders) { + TestBed.overrideProvider(provide, provider); + } const componentContainer = createComponentFixture(sut, wrapper); @@ -158,7 +155,9 @@ export async function render( let result; if (zone) { - await zone.run(() => (result = doNavigate())); + await zone.run(() => { + result = doNavigate(); + }); } else { result = doNavigate(); } @@ -199,7 +198,7 @@ export async function render( if (removeAngularAttributes) { createdFixture.nativeElement.removeAttribute('ng-version'); const idAttribute = createdFixture.nativeElement.getAttribute('id'); - if (idAttribute && idAttribute.startsWith('root')) { + if (idAttribute?.startsWith('root')) { createdFixture.nativeElement.removeAttribute('id'); } } @@ -207,7 +206,9 @@ export async function render( mountedFixtures.add(createdFixture); let isAlive = true; - createdFixture.componentRef.onDestroy(() => (isAlive = false)); + createdFixture.componentRef.onDestroy(() => { + isAlive = false; + }); if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { const changes = getChangesObj(null, componentProperties); @@ -318,10 +319,15 @@ export async function render( }, debugElement: fixture.debugElement, container: fixture.nativeElement, - debug: (element = fixture.nativeElement, maxLength, options) => - Array.isArray(element) - ? element.forEach((e) => console.log(dtlPrettyDOM(e, maxLength, options))) - : console.log(dtlPrettyDOM(element, maxLength, options)), + debug: (element = fixture.nativeElement, maxLength, options) => { + if (Array.isArray(element)) { + for (const e of element) { + console.log(dtlPrettyDOM(e, maxLength, options)); + } + } else { + console.log(dtlPrettyDOM(element, maxLength, options)); + } + }, ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; } @@ -423,9 +429,11 @@ function overrideComponentImports(sut: Type | string, imports: } function overrideChildComponentProviders(componentOverrides: ComponentOverride[]) { - componentOverrides?.forEach(({ component, providers }) => { - TestBed.overrideComponent(component, { set: { providers } }); - }); + if (componentOverrides) { + for (const { component, providers } of componentOverrides) { + TestBed.overrideComponent(component, { set: { providers } }); + } + } } function hasOnChangesHook(componentInstance: SutType): componentInstance is SutType & OnChanges { @@ -439,13 +447,10 @@ function hasOnChangesHook(componentInstance: SutType): componentInstanc function getChangesObj(oldProps: Record | null, newProps: Record) { const isFirstChange = oldProps === null; - return Object.keys(newProps).reduce( - (changes, key) => ({ - ...changes, - [key]: new SimpleChange(isFirstChange ? null : oldProps[key], newProps[key], isFirstChange), - }), - {} as Record, - ); + return Object.keys(newProps).reduce((changes, key) => { + changes[key] = new SimpleChange(isFirstChange ? null : oldProps[key], newProps[key], isFirstChange); + return changes; + }, {} as Record); } function update( @@ -461,10 +466,12 @@ function update( const componentInstance = fixture.componentInstance as Record; const simpleChanges: SimpleChanges = {}; - for (const key of prevRenderedKeys) { - if (!partialUpdate && !Object.prototype.hasOwnProperty.call(newValues, key)) { - simpleChanges[key] = new SimpleChange(componentInstance[key], undefined, false); - delete componentInstance[key]; + if (!partialUpdate) { + for (const key of prevRenderedKeys) { + if (!Object.prototype.hasOwnProperty.call(newValues, key)) { + simpleChanges[key] = new SimpleChange(componentInstance[key], undefined, false); + delete componentInstance[key]; + } } } @@ -643,7 +650,7 @@ function replaceFindWithFindAndDetectChanges>(orig * Call detectChanges for all fixtures */ function detectChangesForMountedFixtures() { - mountedFixtures.forEach((fixture) => { + for (const fixture of mountedFixtures) { try { fixture.detectChanges(); } catch (err: any) { @@ -651,7 +658,7 @@ function detectChangesForMountedFixtures() { throw err; } } - }); + } } /** From 4bd4ab2541a77ebb8b9c265a046f4b17b9bfffe0 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 30 Nov 2024 13:45:33 +0100 Subject: [PATCH 22/42] docs: add Arthie as a contributor for code (#504) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 +++ 2 files changed, 12 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 198cd09..3b3a470 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -410,6 +410,15 @@ "code", "test" ] + }, + { + "login": "Arthie", + "name": "Arthur Petrie", + "avatar_url": "https://avatars.githubusercontent.com/u/16376476?v=4", + "profile": "https://arthurpetrie.com", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 17bffce..c98936d 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,9 @@ Thanks goes to these people ([emoji key][emojis]): Daniel Ramírez Barrientos
Daniel Ramírez Barrientos

💻 Mahdi Lazraq
Mahdi Lazraq

💻 ⚠️ + + Arthur Petrie
Arthur Petrie

💻 + From fdcf5fa5c2e30b535439be0766d0b023fce7c35d Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Tue, 3 Dec 2024 10:12:09 +0100 Subject: [PATCH 23/42] fix: support Angular 19 (#503) --- .github/workflows/ci.yml | 2 +- package.json | 46 +++++++-------- .../src/lib/testing-library.ts | 15 +++-- projects/testing-library/test-setup.ts | 4 +- projects/testing-library/tests/config.spec.ts | 1 + .../testing-library/tests/find-by.spec.ts | 2 + .../testing-library/tests/integration.spec.ts | 38 +++++++------ .../tests/issues/issue-230.spec.ts | 4 +- .../tests/issues/issue-280.spec.ts | 9 +-- .../tests/render-template.spec.ts | 18 +++--- projects/testing-library/tests/render.spec.ts | 57 ++++++++++--------- .../wait-for-element-to-be-removed.spec.ts | 2 + 12 files changed, 111 insertions(+), 87 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28aac0d..5820814 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: install - run: npm install + run: npm install --force - name: build run: npm run build -- --skip-nx-cache - name: test diff --git a/package.json b/package.json index e5030d0..0f8aad8 100644 --- a/package.json +++ b/package.json @@ -27,35 +27,35 @@ "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "18.2.13", - "@angular/cdk": "18.2.14", - "@angular/common": "18.2.13", - "@angular/compiler": "18.2.13", - "@angular/core": "18.2.13", - "@angular/material": "18.2.14", - "@angular/platform-browser": "18.2.13", - "@angular/platform-browser-dynamic": "18.2.13", - "@angular/router": "18.2.13", - "@ngrx/store": "18.0.2", + "@angular/animations": "19.0.1", + "@angular/cdk": "19.0.1", + "@angular/common": "19.0.1", + "@angular/compiler": "19.0.1", + "@angular/core": "19.0.1", + "@angular/material": "19.0.1", + "@angular/platform-browser": "19.0.1", + "@angular/platform-browser-dynamic": "19.0.1", + "@angular/router": "19.0.1", + "@ngrx/store": "19.0.0-beta.0", "@nx/angular": "20.1.3", - "@testing-library/dom": "^10.0.0", + "@testing-library/dom": "^10.4.0", "rxjs": "7.8.0", "tslib": "~2.3.1", - "zone.js": "0.14.10" + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "18.2.9", - "@angular-devkit/core": "18.2.9", - "@angular-devkit/schematics": "18.2.9", + "@angular-devkit/build-angular": "19.0.1", + "@angular-devkit/core": "19.0.1", + "@angular-devkit/schematics": "19.0.1", "@angular-eslint/builder": "18.3.0", "@angular-eslint/eslint-plugin": "18.0.1", "@angular-eslint/eslint-plugin-template": "18.0.1", "@angular-eslint/schematics": "18.3.0", "@angular-eslint/template-parser": "18.0.1", - "@angular/cli": "~18.2.0", - "@angular/compiler-cli": "18.2.13", - "@angular/forms": "18.2.13", - "@angular/language-service": "18.2.13", + "@angular/cli": "19.0.1", + "@angular/compiler-cli": "19.0.1", + "@angular/forms": "19.0.1", + "@angular/language-service": "19.0.1", "@nx/eslint": "20.1.3", "@nx/eslint-plugin": "20.1.3", "@nx/jest": "20.1.3", @@ -68,7 +68,7 @@ "@testing-library/user-event": "^14.4.3", "@types/jasmine": "4.3.1", "@types/jest": "29.5.14", - "@types/node": "18.16.9", + "@types/node": "22.10.1", "@types/testing-library__jasmine-dom": "^1.3.0", "@typescript-eslint/eslint-plugin": "7.16.0", "@typescript-eslint/parser": "7.16.0", @@ -86,7 +86,7 @@ "jasmine-spec-reporter": "7.0.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", - "jest-preset-angular": "14.1.0", + "jest-preset-angular": "14.4.1", "karma": "6.4.0", "karma-chrome-launcher": "^3.1.0", "karma-coverage": "^2.2.1", @@ -94,7 +94,7 @@ "karma-jasmine-html-reporter": "2.0.0", "lint-staged": "^12.1.6", "ng-mocks": "^14.11.0", - "ng-packagr": "18.2.1", + "ng-packagr": "19.0.1", "nx": "20.1.3", "postcss": "^8.4.5", "postcss-import": "14.1.0", @@ -105,6 +105,6 @@ "semantic-release": "^18.0.0", "ts-jest": "29.1.0", "ts-node": "10.9.1", - "typescript": "5.5.4" + "typescript": "5.6.2" } } diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 8dfa946..7c7de89 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -34,6 +34,7 @@ import { RenderComponentOptions, RenderResult, RenderTemplateOptions, + Config, } from './models'; type SubscribedOutput = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription]; @@ -82,7 +83,9 @@ export async function render( configureTestBed = () => { /* noop*/ }, - } = { ...globalConfig, ...renderOptions }; + } = { ...globalConfig, ...renderOptions } as RenderComponentOptions & + RenderTemplateOptions & + Config; dtlConfigure({ eventWrapper: (cb) => { @@ -228,7 +231,7 @@ export async function render( return createdFixture; }; - const fixture = await renderFixture(componentProperties, allInputs, componentOutputs, on); + const fixture = await renderFixture(componentProperties, allInputs as any, componentOutputs, on); if (deferBlockStates) { if (Array.isArray(deferBlockStates)) { @@ -494,12 +497,16 @@ function addAutoDeclarations( wrapper, }: Pick, 'declarations' | 'excludeComponentDeclaration' | 'wrapper'>, ) { + const nonStandaloneDeclarations = declarations?.filter((d) => !isStandalone(d)); if (typeof sut === 'string') { - return [...declarations, wrapper]; + if (wrapper && isStandalone(wrapper)) { + return nonStandaloneDeclarations; + } + return [...nonStandaloneDeclarations, wrapper]; } const components = () => (excludeComponentDeclaration || isStandalone(sut) ? [] : [sut]); - return [...declarations, ...components()]; + return [...nonStandaloneDeclarations, ...components()]; } function addAutoImports( diff --git a/projects/testing-library/test-setup.ts b/projects/testing-library/test-setup.ts index 600d085..8d79c74 100644 --- a/projects/testing-library/test-setup.ts +++ b/projects/testing-library/test-setup.ts @@ -1,6 +1,8 @@ -import 'jest-preset-angular/setup-jest'; +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; import '@testing-library/jest-dom'; import { TextEncoder, TextDecoder } from 'util'; +setupZoneTestEnv(); + // eslint-disable-next-line @typescript-eslint/naming-convention Object.assign(global, { TextDecoder, TextEncoder }); diff --git a/projects/testing-library/tests/config.spec.ts b/projects/testing-library/tests/config.spec.ts index bb8c61f..041d991 100644 --- a/projects/testing-library/tests/config.spec.ts +++ b/projects/testing-library/tests/config.spec.ts @@ -13,6 +13,7 @@ import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; `, + standalone: false, }) class FormsComponent { form = this.formBuilder.group({ diff --git a/projects/testing-library/tests/find-by.spec.ts b/projects/testing-library/tests/find-by.spec.ts index 9d499fd..30f11ee 100644 --- a/projects/testing-library/tests/find-by.spec.ts +++ b/projects/testing-library/tests/find-by.spec.ts @@ -2,10 +2,12 @@ import { Component } from '@angular/core'; import { timer } from 'rxjs'; import { render, screen } from '../src/public_api'; import { mapTo } from 'rxjs/operators'; +import { AsyncPipe } from '@angular/common'; @Component({ selector: 'atl-fixture', template: `
{{ result | async }}
`, + imports: [AsyncPipe], }) class FixtureComponent { result = timer(30).pipe(mapTo('I am visible')); diff --git a/projects/testing-library/tests/integration.spec.ts b/projects/testing-library/tests/integration.spec.ts index eedec0e..02ca290 100644 --- a/projects/testing-library/tests/integration.spec.ts +++ b/projects/testing-library/tests/integration.spec.ts @@ -4,6 +4,7 @@ import { of, BehaviorSubject } from 'rxjs'; import { debounceTime, switchMap, map, startWith } from 'rxjs/operators'; import { render, screen, waitFor, waitForElementToBeRemoved, within } from '../src/lib/testing-library'; import userEvent from '@testing-library/user-event'; +import { AsyncPipe, NgForOf } from '@angular/common'; const DEBOUNCE_TIME = 1_000; @@ -21,6 +22,25 @@ class ModalService { } } +@Component({ + selector: 'atl-table', + template: ` + + + + + +
{{ entity.name }} + +
+ `, + imports: [NgForOf], +}) +class TableComponent { + @Input() entities: any[] = []; + @Output() edit = new EventEmitter(); +} + @Component({ template: `

Entities Title

@@ -31,6 +51,7 @@ class ModalService { `, + imports: [TableComponent, AsyncPipe], }) class EntitiesComponent { query = new BehaviorSubject(''); @@ -55,22 +76,6 @@ class EntitiesComponent { } } -@Component({ - selector: 'atl-table', - template: ` - - - - - -
{{ entity.name }}
- `, -}) -class TableComponent { - @Input() entities: any[] = []; - @Output() edit = new EventEmitter(); -} - const entities = [ { id: 1, @@ -91,7 +96,6 @@ async function setup() { const user = userEvent.setup(); await render(EntitiesComponent, { - declarations: [TableComponent], providers: [ { provide: EntitiesService, diff --git a/projects/testing-library/tests/issues/issue-230.spec.ts b/projects/testing-library/tests/issues/issue-230.spec.ts index fe004b6..8df58f6 100644 --- a/projects/testing-library/tests/issues/issue-230.spec.ts +++ b/projects/testing-library/tests/issues/issue-230.spec.ts @@ -1,8 +1,10 @@ import { Component } from '@angular/core'; import { render, waitFor, screen } from '../../src/public_api'; +import { NgClass } from '@angular/common'; @Component({ template: ` `, + imports: [NgClass], }) class LoopComponent { get classes() { @@ -17,7 +19,7 @@ test('wait does not end up in a loop', async () => { await expect( waitFor(() => { - expect(true).toEqual(false); + expect(true).toBe(false); }), ).rejects.toThrow(); }); diff --git a/projects/testing-library/tests/issues/issue-280.spec.ts b/projects/testing-library/tests/issues/issue-280.spec.ts index 19f644e..5e59534 100644 --- a/projects/testing-library/tests/issues/issue-280.spec.ts +++ b/projects/testing-library/tests/issues/issue-280.spec.ts @@ -1,19 +1,21 @@ import { Location } from '@angular/common'; import { Component, NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { RouterLink, RouterModule, RouterOutlet, Routes } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import userEvent from '@testing-library/user-event'; import { render, screen } from '../../src/public_api'; @Component({ - template: `
Navigate
+ template: `
Navigate
`, + imports: [RouterOutlet], }) class MainComponent {} @Component({ - template: `
first page
+ template: `
first page
go to second`, + imports: [RouterLink], }) class FirstComponent {} @@ -35,7 +37,6 @@ const routes: Routes = [ ]; @NgModule({ - declarations: [FirstComponent, SecondComponent], imports: [RouterModule.forRoot(routes)], exports: [RouterModule], }) diff --git a/projects/testing-library/tests/render-template.spec.ts b/projects/testing-library/tests/render-template.spec.ts index a6892db..e185f70 100644 --- a/projects/testing-library/tests/render-template.spec.ts +++ b/projects/testing-library/tests/render-template.spec.ts @@ -45,7 +45,7 @@ class GreetingComponent { test('the directive renders', async () => { const view = await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], }); // eslint-disable-next-line testing-library/no-container @@ -54,7 +54,7 @@ test('the directive renders', async () => { test('the component renders', async () => { const view = await render('', { - declarations: [GreetingComponent], + imports: [GreetingComponent], }); // eslint-disable-next-line testing-library/no-container @@ -64,7 +64,7 @@ test('the component renders', async () => { test('uses the default props', async () => { await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], }); fireEvent.click(screen.getByText('init')); @@ -74,7 +74,7 @@ test('uses the default props', async () => { test('overrides input properties', async () => { await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], }); fireEvent.click(screen.getByText('init')); @@ -85,7 +85,7 @@ test('overrides input properties', async () => { test('overrides input properties via a wrapper', async () => { // `bar` will be set as a property on the wrapper component, the property will be used to pass to the directive await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], componentProperties: { bar: 'hello', }, @@ -100,7 +100,7 @@ test('overrides output properties', async () => { const clicked = jest.fn(); await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], componentProperties: { clicked, }, @@ -116,7 +116,7 @@ test('overrides output properties', async () => { describe('removeAngularAttributes', () => { it('should remove angular attributes', async () => { await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], removeAngularAttributes: true, }); @@ -126,7 +126,7 @@ describe('removeAngularAttributes', () => { it('is disabled by default', async () => { await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], }); expect(document.querySelector('[ng-version]')).not.toBeNull(); @@ -136,7 +136,7 @@ describe('removeAngularAttributes', () => { test('updates properties and invokes change detection', async () => { const view = await render<{ value: string }>('
', { - declarations: [UpdateInputDirective], + imports: [UpdateInputDirective], componentProperties: { value: 'value1', }, diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 59e0f75..52d318c 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -47,33 +47,31 @@ describe('DTL functionality', () => { }); }); -describe('standalone', () => { +describe('components', () => { @Component({ selector: 'atl-fixture', template: ` {{ name }} `, }) - class StandaloneFixtureComponent { + class FixtureWithInputComponent { @Input() name = ''; } - it('renders standalone component', async () => { - await render(StandaloneFixtureComponent, { componentProperties: { name: 'Bob' } }); + it('renders component', async () => { + await render(FixtureWithInputComponent, { componentProperties: { name: 'Bob' } }); expect(screen.getByText('Bob')).toBeInTheDocument(); }); }); -describe('standalone with child', () => { +describe('component with child', () => { @Component({ selector: 'atl-child-fixture', template: `A child fixture`, - standalone: true, }) class ChildFixtureComponent {} @Component({ selector: 'atl-child-fixture', template: `A mock child fixture`, - standalone: true, // eslint-disable-next-line @angular-eslint/no-host-metadata-property, @typescript-eslint/naming-convention host: { 'collision-id': MockChildFixtureComponent.name }, }) @@ -83,18 +81,17 @@ describe('standalone with child', () => { selector: 'atl-parent-fixture', template: `

Parent fixture

`, - standalone: true, imports: [ChildFixtureComponent], }) class ParentFixtureComponent {} - it('renders the standalone component with a mocked child', async () => { + it('renders the component with a mocked child', async () => { await render(ParentFixtureComponent, { componentImports: [MockChildFixtureComponent] }); expect(screen.getByText('Parent fixture')).toBeInTheDocument(); expect(screen.getByText('A mock child fixture')).toBeInTheDocument(); }); - it('renders the standalone component with child', async () => { + it('renders the component with child', async () => { await render(ParentFixtureComponent); expect(screen.getByText('Parent fixture')).toBeInTheDocument(); expect(screen.getByText('A child fixture')).toBeInTheDocument(); @@ -118,7 +115,6 @@ describe('childComponentOverrides', () => { @Component({ selector: 'atl-child-fixture', template: `{{ simpleService.value }}`, - standalone: true, providers: [MySimpleService], }) class NestedChildFixtureComponent { @@ -128,7 +124,6 @@ describe('childComponentOverrides', () => { @Component({ selector: 'atl-parent-fixture', template: ``, - standalone: true, imports: [NestedChildFixtureComponent], }) class ParentFixtureComponent {} @@ -190,22 +185,22 @@ describe('componentOutputs', () => { }); describe('on', () => { - @Component({ template: ``, standalone: true }) + @Component({ template: `` }) class TestFixtureWithEventEmitterComponent { @Output() readonly event = new EventEmitter(); } - @Component({ template: ``, standalone: true }) + @Component({ template: `` }) class TestFixtureWithDerivedEventComponent { @Output() readonly event = fromEvent(inject(ElementRef).nativeElement, 'click'); } - @Component({ template: ``, standalone: true }) + @Component({ template: `` }) class TestFixtureWithFunctionalOutputComponent { readonly event = output(); } - @Component({ template: ``, standalone: true }) + @Component({ template: `` }) class TestFixtureWithFunctionalDerivedEventComponent { readonly event = outputFromObservable(fromEvent(inject(ElementRef).nativeElement, 'click')); } @@ -313,20 +308,31 @@ describe('on', () => { }); }); -describe('animationModule', () => { +describe('excludeComponentDeclaration', () => { + @Component({ + selector: 'atl-fixture', + template: ` + + + `, + standalone: false, + }) + class NotStandaloneFixtureComponent {} + @NgModule({ - declarations: [FixtureComponent], + declarations: [NotStandaloneFixtureComponent], }) class FixtureModule {} - describe('excludeComponentDeclaration', () => { - it('does not throw if component is declared in an imported module', async () => { - await render(FixtureComponent, { - imports: [FixtureModule], - excludeComponentDeclaration: true, - }); + + it('does not throw if component is declared in an imported module', async () => { + await render(NotStandaloneFixtureComponent, { + imports: [FixtureModule], + excludeComponentDeclaration: true, }); }); +}); +describe('animationModule', () => { it('adds NoopAnimationsModule by default', async () => { await render(FixtureComponent); const noopAnimationsModule = TestBed.inject(NoopAnimationsModule); @@ -458,14 +464,12 @@ describe('DebugElement', () => { describe('initialRoute', () => { @Component({ - standalone: true, selector: 'atl-fixture2', template: ``, }) class SecondaryFixtureComponent {} @Component({ - standalone: true, selector: 'atl-router-fixture', template: ``, imports: [RouterModule], @@ -502,7 +506,6 @@ describe('initialRoute', () => { it('allows initially rendering a specific route with query parameters', async () => { @Component({ - standalone: true, selector: 'atl-query-param-fixture', template: `

paramPresent$: {{ paramPresent$ | async }}

`, imports: [NgIf, AsyncPipe], diff --git a/projects/testing-library/tests/wait-for-element-to-be-removed.spec.ts b/projects/testing-library/tests/wait-for-element-to-be-removed.spec.ts index 5c16a53..64d6c35 100644 --- a/projects/testing-library/tests/wait-for-element-to-be-removed.spec.ts +++ b/projects/testing-library/tests/wait-for-element-to-be-removed.spec.ts @@ -1,10 +1,12 @@ import { Component, OnInit } from '@angular/core'; import { render, screen, waitForElementToBeRemoved } from '../src/public_api'; import { timer } from 'rxjs'; +import { NgIf } from '@angular/common'; @Component({ selector: 'atl-fixture', template: `
👋
`, + imports: [NgIf], }) class FixtureComponent implements OnInit { visible = true; From d59e4f213a4441e63b3157f83e54e9e9d6070a6d Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Sun, 15 Dec 2024 01:35:56 +0900 Subject: [PATCH 24/42] fix: flatten component providers for consistency to the framework (#507) Closes #506 --- .../src/lib/testing-library.ts | 3 ++- .../providers/component-provider.spec.ts | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 7c7de89..2ddf389 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -119,7 +119,8 @@ export async function render( await TestBed.compileComponents(); - for (const { provide, ...provider } of componentProviders) { + // Angular supports nested arrays of providers, so we need to flatten them to emulate the same behavior. + for (const { provide, ...provider } of componentProviders.flat(Infinity)) { TestBed.overrideProvider(provide, provider); } diff --git a/projects/testing-library/tests/providers/component-provider.spec.ts b/projects/testing-library/tests/providers/component-provider.spec.ts index 3c3ec0c..9290d5b 100644 --- a/projects/testing-library/tests/providers/component-provider.spec.ts +++ b/projects/testing-library/tests/providers/component-provider.spec.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Provider } from '@angular/core'; import { Component } from '@angular/core'; import { render, screen } from '../../src/public_api'; @@ -42,6 +42,24 @@ test('shows the provided service value with template syntax', async () => { expect(screen.getByText('bar')).toBeInTheDocument(); }); +test('flatten the nested array of component providers', async () => { + const provideService = (): Provider => [ + { + provide: Service, + useValue: { + foo() { + return 'bar'; + }, + }, + }, + ]; + await render(FixtureComponent, { + componentProviders: [provideService()], + }); + + expect(screen.getByText('bar')).toBeInTheDocument(); +}); + @Injectable() class Service { foo() { From 5ef0863a2e984d47e077ada300e87d8d3adbfabf Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Sat, 21 Dec 2024 18:58:26 +0100 Subject: [PATCH 25/42] docs: add Angular 19 to Version compatibility table (#509) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c98936d..5ecd39e 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ You may also be interested in installing `jest-dom` so you can use | Angular | Angular Testing Library | | ------- | ---------------------------- | +| 19.x | 17.x, 16.x, 15.x, 14.x, 13.x | | 18.x | 17.x, 16.x, 15.x, 14.x, 13.x | | 17.x | 17.x, 16.x, 15.x, 14.x, 13.x | | 16.x | 14.x, 13.x | From 33af2876d3dca98e52b6633b70cf088f7354071a Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Fri, 3 Jan 2025 20:20:39 +0100 Subject: [PATCH 26/42] chore: update deps (#510) --- .devcontainer/devcontainer.json | 2 +- .github/workflows/ci.yml | 2 +- .node-version | 2 +- package.json | 34 ++++++++++++++++----------------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index db976dc..72780fc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/devcontainer.json. { "name": "angular-testing-library", - "image": "mcr.microsoft.com/devcontainers/typescript-node:0-20-bullseye", + "image": "mcr.microsoft.com/devcontainers/typescript-node:0-22-bullseye", // Features to add to the dev container. More info: https://containers.dev/features. "features": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5820814..9955c0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: - node-version: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '[20]' || '[18, 20]') }} + node-version: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '[22]' || '[18, 20, 22]') }} os: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '["ubuntu-latest"]' || '["ubuntu-latest", "windows-latest"]') }} runs-on: ${{ matrix.os }} diff --git a/.node-version b/.node-version index 914d1a7..8fdd954 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.9 \ No newline at end of file +22 \ No newline at end of file diff --git a/package.json b/package.json index 0f8aad8..16ac142 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "@angular/platform-browser": "19.0.1", "@angular/platform-browser-dynamic": "19.0.1", "@angular/router": "19.0.1", - "@ngrx/store": "19.0.0-beta.0", - "@nx/angular": "20.1.3", + "@ngrx/store": "19.0.0", + "@nx/angular": "20.3.0", "@testing-library/dom": "^10.4.0", "rxjs": "7.8.0", "tslib": "~2.3.1", @@ -47,21 +47,21 @@ "@angular-devkit/build-angular": "19.0.1", "@angular-devkit/core": "19.0.1", "@angular-devkit/schematics": "19.0.1", - "@angular-eslint/builder": "18.3.0", - "@angular-eslint/eslint-plugin": "18.0.1", - "@angular-eslint/eslint-plugin-template": "18.0.1", - "@angular-eslint/schematics": "18.3.0", - "@angular-eslint/template-parser": "18.0.1", - "@angular/cli": "19.0.1", + "@angular-eslint/builder": "19.0.2", + "@angular-eslint/eslint-plugin": "19.0.2", + "@angular-eslint/eslint-plugin-template": "19.0.2", + "@angular-eslint/schematics": "19.0.2", + "@angular-eslint/template-parser": "19.0.2", + "@angular/cli": "~19.0.0", "@angular/compiler-cli": "19.0.1", "@angular/forms": "19.0.1", "@angular/language-service": "19.0.1", - "@nx/eslint": "20.1.3", - "@nx/eslint-plugin": "20.1.3", - "@nx/jest": "20.1.3", - "@nx/node": "20.1.3", - "@nx/plugin": "20.1.3", - "@nx/workspace": "20.1.3", + "@nx/eslint": "20.3.0", + "@nx/eslint-plugin": "20.3.0", + "@nx/jest": "20.3.0", + "@nx/node": "20.3.0", + "@nx/plugin": "20.3.0", + "@nx/workspace": "20.3.0", "@schematics/angular": "18.2.9", "@testing-library/jasmine-dom": "^1.2.0", "@testing-library/jest-dom": "^5.16.5", @@ -86,16 +86,16 @@ "jasmine-spec-reporter": "7.0.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", - "jest-preset-angular": "14.4.1", + "jest-preset-angular": "14.4.2", "karma": "6.4.0", "karma-chrome-launcher": "^3.1.0", "karma-coverage": "^2.2.1", "karma-jasmine": "5.1.0", "karma-jasmine-html-reporter": "2.0.0", "lint-staged": "^12.1.6", - "ng-mocks": "^14.11.0", + "ng-mocks": "^14.13.1", "ng-packagr": "19.0.1", - "nx": "20.1.3", + "nx": "20.3.0", "postcss": "^8.4.5", "postcss-import": "14.1.0", "postcss-preset-env": "7.5.0", From e0cd81e6a881dafe92cad10d19ecef26be977f88 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Sat, 4 Jan 2025 13:51:48 +0100 Subject: [PATCH 27/42] chore: migrate to eslint flat config --- .eslintignore | 1 - .eslintrc.json | 127 ------------------ .github/workflows/ci.yml | 2 + README.md | 2 +- apps/example-app-karma/.eslintrc.json | 44 ------ apps/example-app-karma/eslint.config.cjs | 7 + apps/example-app-karma/eslint.config.mjs | 8 ++ apps/example-app-karma/jasmine-dom.d.ts | 1 - .../src/app/examples/login-form.spec.ts | 4 +- .../src/app/issues/rerender.spec.ts | 4 +- apps/example-app/.eslintrc.json | 47 ------- apps/example-app/eslint.config.cjs | 7 + apps/example-app/eslint.config.mjs | 8 ++ apps/example-app/jest.config.ts | 1 - .../src/app/examples/00-single-component.ts | 2 +- .../src/app/examples/01-nested-component.ts | 12 +- .../src/app/examples/02-input-output.spec.ts | 4 +- .../src/app/examples/02-input-output.ts | 2 +- apps/example-app/src/app/examples/03-forms.ts | 2 +- .../app/examples/04-forms-with-material.ts | 2 +- .../src/app/examples/05-component-provider.ts | 2 +- .../src/app/examples/06-with-ngrx-store.ts | 2 +- .../app/examples/07-with-ngrx-mock-store.ts | 6 +- .../src/app/examples/08-directive.spec.ts | 8 +- .../src/app/examples/08-directive.ts | 2 +- .../example-app/src/app/examples/09-router.ts | 6 +- .../examples/10-inject-token-dependency.ts | 2 +- .../src/app/examples/11-ng-content.spec.ts | 2 +- .../src/app/examples/11-ng-content.ts | 2 +- .../src/app/examples/12-service-component.ts | 2 +- .../app/examples/13-scrolling.component.ts | 2 +- .../app/examples/14-async-component.spec.ts | 1 - .../src/app/examples/14-async-component.ts | 2 +- .../app/examples/15-dialog.component.spec.ts | 2 +- .../src/app/examples/15-dialog.component.ts | 4 +- .../app/examples/16-input-getter-setter.ts | 2 +- ...-component-with-attribute-selector.spec.ts | 2 +- .../17-component-with-attribute-selector.ts | 2 +- .../app/examples/19-standalone-component.ts | 6 +- .../src/app/examples/20-test-harness.spec.ts | 4 +- .../src/app/examples/20-test-harness.ts | 2 +- .../examples/21-deferable-view.component.ts | 4 +- .../22-signal-inputs.component.spec.ts | 2 +- .../examples/22-signal-inputs.component.ts | 6 +- apps/example-app/src/test-setup.ts | 4 +- eslint.config.cjs | 7 + eslint.config.mjs | 66 +++++++++ nx.json | 4 +- package.json | 48 ++++--- projects/testing-library/.eslintrc.json | 62 --------- projects/testing-library/eslint.config.cjs | 7 + projects/testing-library/eslint.config.mjs | 8 ++ projects/testing-library/jest.config.ts | 1 - .../dtl-as-dev-dependency/index.spec.ts | 2 - .../schematics/ng-add/schema.ts | 1 - projects/testing-library/src/lib/models.ts | 4 +- .../src/lib/testing-library.ts | 2 +- projects/testing-library/test-setup.ts | 1 - projects/testing-library/tests/debug.spec.ts | 8 +- .../tests/issues/issue-280.spec.ts | 4 +- .../tests/issues/issue-389.spec.ts | 1 - .../issue-396-standalone-stub-child.spec.ts | 1 - ...ssue-398-component-without-host-id.spec.ts | 2 - .../issue-422-view-already-destroyed.spec.ts | 1 + .../tests/issues/issue-437.spec.ts | 2 - .../tests/issues/issue-493.spec.ts | 2 +- projects/testing-library/tests/render.spec.ts | 19 ++- 67 files changed, 222 insertions(+), 397 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.json delete mode 100644 apps/example-app-karma/.eslintrc.json create mode 100644 apps/example-app-karma/eslint.config.cjs create mode 100644 apps/example-app-karma/eslint.config.mjs delete mode 100644 apps/example-app/.eslintrc.json create mode 100644 apps/example-app/eslint.config.cjs create mode 100644 apps/example-app/eslint.config.mjs create mode 100644 eslint.config.cjs create mode 100644 eslint.config.mjs delete mode 100644 projects/testing-library/.eslintrc.json create mode 100644 projects/testing-library/eslint.config.cjs create mode 100644 projects/testing-library/eslint.config.mjs diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 3c3629e..0000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -node_modules diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 0a96094..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "root": true, - "ignorePatterns": ["**/*"], - "plugins": ["@nx", "testing-library"], - "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "rules": { - "@nx/enforce-module-boundaries": [ - "error", - { - "enforceBuildableLibDependency": true, - "allow": [], - "depConstraints": [ - { - "sourceTag": "*", - "onlyDependOnLibsWithTags": ["*"] - } - ] - } - ] - } - }, - { - "files": ["*.ts", "*.tsx"], - "extends": ["plugin:@nx/typescript"], - "rules": { - "@typescript-eslint/no-extra-semi": "error", - "no-extra-semi": "off" - } - }, - { - "files": ["*.js", "*.jsx"], - "extends": ["plugin:@nx/javascript"], - "rules": { - "@typescript-eslint/no-extra-semi": "error", - "no-extra-semi": "off" - } - }, - { - "files": ["*.ts"], - "plugins": ["eslint-plugin-import", "@angular-eslint/eslint-plugin", "@typescript-eslint"], - "rules": { - "@typescript-eslint/consistent-type-definitions": "error", - "@typescript-eslint/dot-notation": "off", - "@typescript-eslint/naming-convention": "error", - "@typescript-eslint/no-shadow": [ - "error", - { - "hoist": "all" - } - ], - "@typescript-eslint/no-unused-expressions": "error", - "@typescript-eslint/prefer-function-type": "error", - "@typescript-eslint/quotes": "off", - "@typescript-eslint/type-annotation-spacing": "error", - "@typescript-eslint/no-explicit-any": "off", - "arrow-body-style": "off", - "brace-style": ["error", "1tbs"], - "curly": "error", - "eol-last": "error", - "eqeqeq": ["error", "smart"], - "guard-for-in": "error", - "id-blacklist": "off", - "id-match": "off", - "import/no-deprecated": "warn", - "no-bitwise": "error", - "no-caller": "error", - "no-console": [ - "error", - { - "allow": [ - "log", - "warn", - "dir", - "timeLog", - "assert", - "clear", - "count", - "countReset", - "group", - "groupEnd", - "table", - "dirxml", - "error", - "groupCollapsed", - "Console", - "profile", - "profileEnd", - "timeStamp", - "context" - ] - } - ], - "no-empty": "off", - "no-eval": "error", - "no-new-wrappers": "error", - "no-throw-literal": "error", - "no-undef-init": "error", - "no-underscore-dangle": "off", - "radix": "error", - "spaced-comment": [ - "error", - "always", - { - "markers": ["/"] - } - ] - } - }, - { - "files": ["*.html"], - "rules": {} - }, - { - "files": ["*.ts", "*.js"], - "extends": ["prettier"] - }, - { - "files": ["*.spec.ts"], - "extends": ["plugin:testing-library/angular"], - "rules": { - "testing-library/prefer-explicit-assert": "error" - } - } - ] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9955c0d..215ef7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: run: npm run build -- --skip-nx-cache - name: test run: npm run test + - name: lint + run: npm run lint - name: Release if: github.repository == 'testing-library/angular-testing-library' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') run: npx semantic-release diff --git a/README.md b/README.md index 5ecd39e..e03ce68 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ counter.component.ts ```ts @Component({ - selector: 'app-counter', + selector: 'atl-counter', template: ` {{ hello() }} diff --git a/apps/example-app-karma/.eslintrc.json b/apps/example-app-karma/.eslintrc.json deleted file mode 100644 index 404aa66..0000000 --- a/apps/example-app-karma/.eslintrc.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "extends": "../../.eslintrc.json", - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts"], - "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], - "parserOptions": { - "project": ["apps/example-app-karma/tsconfig.*?.json"] - }, - "rules": { - "@angular-eslint/directive-selector": [ - "error", - { - "type": "attribute", - "prefix": "app", - "style": "camelCase" - } - ], - "@angular-eslint/component-selector": [ - "error", - { - "type": "element", - "prefix": "app", - "style": "kebab-case" - } - ] - } - }, - { - "files": ["*.spec.ts"], - "env": { - "jasmine": true - }, - "plugins": ["jasmine"], - "extends": ["plugin:jasmine/recommended"] - }, - { - "files": ["*.html"], - "extends": ["plugin:@nx/angular-template"], - "rules": {} - } - ] -} diff --git a/apps/example-app-karma/eslint.config.cjs b/apps/example-app-karma/eslint.config.cjs new file mode 100644 index 0000000..9e951e7 --- /dev/null +++ b/apps/example-app-karma/eslint.config.cjs @@ -0,0 +1,7 @@ +// @ts-check + +// TODO - https://github.com/nrwl/nx/issues/22576 + +/** @type {import('@typescript-eslint/utils/ts-eslint').FlatConfig.ConfigPromise} */ +const config = (async () => (await import('./eslint.config.mjs')).default)(); +module.exports = config; diff --git a/apps/example-app-karma/eslint.config.mjs b/apps/example-app-karma/eslint.config.mjs new file mode 100644 index 0000000..8f627db --- /dev/null +++ b/apps/example-app-karma/eslint.config.mjs @@ -0,0 +1,8 @@ +// @ts-check + +import tseslint from "typescript-eslint"; +import rootConfig from "../../eslint.config.mjs"; + +export default tseslint.config( + ...rootConfig, +); diff --git a/apps/example-app-karma/jasmine-dom.d.ts b/apps/example-app-karma/jasmine-dom.d.ts index f8fa4a7..54d7903 100644 --- a/apps/example-app-karma/jasmine-dom.d.ts +++ b/apps/example-app-karma/jasmine-dom.d.ts @@ -1,5 +1,4 @@ declare module '@testing-library/jasmine-dom' { - // eslint-disable-next-line @typescript-eslint/naming-convention const JasmineDOM: any; export default JasmineDOM; } diff --git a/apps/example-app-karma/src/app/examples/login-form.spec.ts b/apps/example-app-karma/src/app/examples/login-form.spec.ts index 9c51065..a028234 100644 --- a/apps/example-app-karma/src/app/examples/login-form.spec.ts +++ b/apps/example-app-karma/src/app/examples/login-form.spec.ts @@ -29,7 +29,7 @@ it('should display invalid message and submit button must be disabled', async () }); @Component({ - selector: 'app-login', + selector: 'atl-login', standalone: true, imports: [ReactiveFormsModule, NgIf], template: ` @@ -51,7 +51,7 @@ class LoginComponent { }); constructor(private fb: FormBuilder) {} - + get email(): FormControl { return this.form.get('email') as FormControl; } diff --git a/apps/example-app-karma/src/app/issues/rerender.spec.ts b/apps/example-app-karma/src/app/issues/rerender.spec.ts index 9b044d1..324e8a1 100644 --- a/apps/example-app-karma/src/app/issues/rerender.spec.ts +++ b/apps/example-app-karma/src/app/issues/rerender.spec.ts @@ -7,9 +7,9 @@ it('can rerender component', async () => { }, }); - expect(screen.getByText('Hello Sarah')).toBeTruthy(); + expect(screen.getByText('Hello Sarah')).toBeInTheDocument(); await rerender({ componentProperties: { name: 'Mark' } }); - expect(screen.getByText('Hello Mark')).toBeTruthy(); + expect(screen.getByText('Hello Mark')).toBeInTheDocument(); }); diff --git a/apps/example-app/.eslintrc.json b/apps/example-app/.eslintrc.json deleted file mode 100644 index ed5e4d1..0000000 --- a/apps/example-app/.eslintrc.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "extends": "../../.eslintrc.json", - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts"], - "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], - "parserOptions": { - "project": ["apps/example-app/tsconfig.*?.json"] - }, - "rules": { - "@angular-eslint/directive-selector": [ - "error", - { - "type": "attribute", - "prefix": "app", - "style": "camelCase" - } - ], - "@angular-eslint/component-selector": [ - "error", - { - "type": "element", - "prefix": "app", - "style": "kebab-case" - } - ] - } - }, - { - "files": ["*.spec.ts"], - "env": { - "jest": true - }, - "extends": ["plugin:jest/recommended", "plugin:jest/style", "plugin:jest-dom/recommended"], - "rules": { - "jest/consistent-test-it": ["error"], - "jest/expect-expect": "off" - } - }, - { - "files": ["*.html"], - "extends": ["plugin:@nx/angular-template"], - "rules": {} - } - ] -} diff --git a/apps/example-app/eslint.config.cjs b/apps/example-app/eslint.config.cjs new file mode 100644 index 0000000..9e951e7 --- /dev/null +++ b/apps/example-app/eslint.config.cjs @@ -0,0 +1,7 @@ +// @ts-check + +// TODO - https://github.com/nrwl/nx/issues/22576 + +/** @type {import('@typescript-eslint/utils/ts-eslint').FlatConfig.ConfigPromise} */ +const config = (async () => (await import('./eslint.config.mjs')).default)(); +module.exports = config; diff --git a/apps/example-app/eslint.config.mjs b/apps/example-app/eslint.config.mjs new file mode 100644 index 0000000..0162584 --- /dev/null +++ b/apps/example-app/eslint.config.mjs @@ -0,0 +1,8 @@ +// @ts-check + +import tseslint from "typescript-eslint"; +import rootConfig from "../../eslint.config.mjs"; + +export default tseslint.config( + ...rootConfig, +); \ No newline at end of file diff --git a/apps/example-app/jest.config.ts b/apps/example-app/jest.config.ts index 4b0c248..e0ea9c2 100644 --- a/apps/example-app/jest.config.ts +++ b/apps/example-app/jest.config.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ export default { displayName: { name: 'Example App', diff --git a/apps/example-app/src/app/examples/00-single-component.ts b/apps/example-app/src/app/examples/00-single-component.ts index 7c132c2..4a09239 100644 --- a/apps/example-app/src/app/examples/00-single-component.ts +++ b/apps/example-app/src/app/examples/00-single-component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; @Component({ - selector: 'app-fixture', + selector: 'atl-fixture', standalone: true, template: ` diff --git a/apps/example-app/src/app/examples/01-nested-component.ts b/apps/example-app/src/app/examples/01-nested-component.ts index 645ce96..fd0d0c0 100644 --- a/apps/example-app/src/app/examples/01-nested-component.ts +++ b/apps/example-app/src/app/examples/01-nested-component.ts @@ -2,7 +2,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; @Component({ standalone: true, - selector: 'app-button', + selector: 'atl-button', template: ' ', }) export class NestedButtonComponent { @@ -12,7 +12,7 @@ export class NestedButtonComponent { @Component({ standalone: true, - selector: 'app-value', + selector: 'atl-value', template: ' {{ value }} ', }) export class NestedValueComponent { @@ -21,11 +21,11 @@ export class NestedValueComponent { @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: ` - - - + + + `, imports: [NestedButtonComponent, NestedValueComponent], }) diff --git a/apps/example-app/src/app/examples/02-input-output.spec.ts b/apps/example-app/src/app/examples/02-input-output.spec.ts index 847f6e1..5a55bd5 100644 --- a/apps/example-app/src/app/examples/02-input-output.spec.ts +++ b/apps/example-app/src/app/examples/02-input-output.spec.ts @@ -36,7 +36,7 @@ test.skip('is possible to set input and listen for output with the template synt const user = userEvent.setup(); const sendSpy = jest.fn(); - await render('', { + await render('', { imports: [InputOutputComponent], on: { sendValue: sendSpy, @@ -94,7 +94,7 @@ test('is possible to set input and listen for output with the template syntax (d const user = userEvent.setup(); const sendSpy = jest.fn(); - await render('', { + await render('', { imports: [InputOutputComponent], componentProperties: { sendValue: sendSpy, diff --git a/apps/example-app/src/app/examples/02-input-output.ts b/apps/example-app/src/app/examples/02-input-output.ts index 5bf70ab..3d7f979 100644 --- a/apps/example-app/src/app/examples/02-input-output.ts +++ b/apps/example-app/src/app/examples/02-input-output.ts @@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: ` {{ value }} diff --git a/apps/example-app/src/app/examples/03-forms.ts b/apps/example-app/src/app/examples/03-forms.ts index 49756dc..a62d865 100644 --- a/apps/example-app/src/app/examples/03-forms.ts +++ b/apps/example-app/src/app/examples/03-forms.ts @@ -4,7 +4,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', imports: [ReactiveFormsModule, NgForOf, NgIf], template: `
diff --git a/apps/example-app/src/app/examples/04-forms-with-material.ts b/apps/example-app/src/app/examples/04-forms-with-material.ts index ef80493..cf117a5 100644 --- a/apps/example-app/src/app/examples/04-forms-with-material.ts +++ b/apps/example-app/src/app/examples/04-forms-with-material.ts @@ -18,7 +18,7 @@ import { MatNativeDateModule } from '@angular/material/core'; NgForOf, NgIf, ], - selector: 'app-fixture', + selector: 'atl-fixture', template: ` diff --git a/apps/example-app/src/app/examples/05-component-provider.ts b/apps/example-app/src/app/examples/05-component-provider.ts index 1f345b9..2d66b07 100644 --- a/apps/example-app/src/app/examples/05-component-provider.ts +++ b/apps/example-app/src/app/examples/05-component-provider.ts @@ -21,7 +21,7 @@ export class CounterService { @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: ` {{ counter.value() }} diff --git a/apps/example-app/src/app/examples/06-with-ngrx-store.ts b/apps/example-app/src/app/examples/06-with-ngrx-store.ts index b1db1d4..8702843 100644 --- a/apps/example-app/src/app/examples/06-with-ngrx-store.ts +++ b/apps/example-app/src/app/examples/06-with-ngrx-store.ts @@ -18,7 +18,7 @@ const selectValue = createSelector( @Component({ standalone: true, imports: [AsyncPipe], - selector: 'app-fixture', + selector: 'atl-fixture', template: ` {{ value | async }} diff --git a/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts b/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts index 7754bf1..915a88d 100644 --- a/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts +++ b/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts @@ -10,10 +10,12 @@ export const selectItems = createSelector( @Component({ standalone: true, imports: [AsyncPipe, NgForOf], - selector: 'app-fixture', + selector: 'atl-fixture', template: `
    -
  • {{ item }}
  • +
  • + +
`, }) diff --git a/apps/example-app/src/app/examples/08-directive.spec.ts b/apps/example-app/src/app/examples/08-directive.spec.ts index 8b70b38..28a41e9 100644 --- a/apps/example-app/src/app/examples/08-directive.spec.ts +++ b/apps/example-app/src/app/examples/08-directive.spec.ts @@ -6,7 +6,7 @@ import { SpoilerDirective } from './08-directive'; test('it is possible to test directives with container component', async () => { @Component({ - template: `
`, + template: `
`, imports: [SpoilerDirective], standalone: true, }) @@ -32,7 +32,7 @@ test('it is possible to test directives with container component', async () => { test('it is possible to test directives', async () => { const user = userEvent.setup(); - await render('
', { + await render('
', { imports: [SpoilerDirective], }); @@ -55,7 +55,7 @@ test('it is possible to test directives with props', async () => { const hidden = 'SPOILER ALERT'; const visible = 'There is nothing to see here ...'; - await render('
', { + await render('
', { imports: [SpoilerDirective], componentProperties: { hidden, @@ -80,7 +80,7 @@ test('it is possible to test directives with props in template', async () => { const hidden = 'SPOILER ALERT'; const visible = 'There is nothing to see here ...'; - await render(``, { + await render(``, { imports: [SpoilerDirective], }); diff --git a/apps/example-app/src/app/examples/08-directive.ts b/apps/example-app/src/app/examples/08-directive.ts index 40548b1..63efe41 100644 --- a/apps/example-app/src/app/examples/08-directive.ts +++ b/apps/example-app/src/app/examples/08-directive.ts @@ -2,7 +2,7 @@ import { Directive, HostListener, ElementRef, Input, OnInit } from '@angular/cor @Directive({ standalone: true, - selector: '[appSpoiler]', + selector: '[atlSpoiler]', }) export class SpoilerDirective implements OnInit { @Input() hidden = 'SPOILER'; diff --git a/apps/example-app/src/app/examples/09-router.ts b/apps/example-app/src/app/examples/09-router.ts index 888d7fd..e46773b 100644 --- a/apps/example-app/src/app/examples/09-router.ts +++ b/apps/example-app/src/app/examples/09-router.ts @@ -6,7 +6,7 @@ import { map } from 'rxjs/operators'; @Component({ standalone: true, imports: [RouterLink, RouterOutlet], - selector: 'app-main', + selector: 'atl-main', template: ` Load one | Load two | Load three | @@ -21,7 +21,7 @@ export class RootComponent {} @Component({ standalone: true, imports: [RouterLink, AsyncPipe], - selector: 'app-detail', + selector: 'atl-detail', template: `

Detail {{ id | async }}

@@ -40,7 +40,7 @@ export class DetailComponent { @Component({ standalone: true, - selector: 'app-detail-hidden', + selector: 'atl-detail-hidden', template: ' You found the treasure! ', }) export class HiddenDetailComponent {} diff --git a/apps/example-app/src/app/examples/10-inject-token-dependency.ts b/apps/example-app/src/app/examples/10-inject-token-dependency.ts index 6a17d53..f7b2f66 100644 --- a/apps/example-app/src/app/examples/10-inject-token-dependency.ts +++ b/apps/example-app/src/app/examples/10-inject-token-dependency.ts @@ -4,7 +4,7 @@ export const DATA = new InjectionToken<{ text: string }>('Components Data'); @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: ' {{ data.text }} ', }) export class DataInjectedComponent { diff --git a/apps/example-app/src/app/examples/11-ng-content.spec.ts b/apps/example-app/src/app/examples/11-ng-content.spec.ts index 2f91025..468a3f2 100644 --- a/apps/example-app/src/app/examples/11-ng-content.spec.ts +++ b/apps/example-app/src/app/examples/11-ng-content.spec.ts @@ -5,7 +5,7 @@ import { CellComponent } from './11-ng-content'; test('it is possible to test ng-content without selector', async () => { const projection = 'it should be showed into a p element!'; - await render(`${projection}`, { + await render(`${projection}`, { imports: [CellComponent], }); diff --git a/apps/example-app/src/app/examples/11-ng-content.ts b/apps/example-app/src/app/examples/11-ng-content.ts index d444683..0dd668b 100644 --- a/apps/example-app/src/app/examples/11-ng-content.ts +++ b/apps/example-app/src/app/examples/11-ng-content.ts @@ -2,7 +2,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: `

diff --git a/apps/example-app/src/app/examples/12-service-component.ts b/apps/example-app/src/app/examples/12-service-component.ts index 2aed165..1746eb2 100644 --- a/apps/example-app/src/app/examples/12-service-component.ts +++ b/apps/example-app/src/app/examples/12-service-component.ts @@ -19,7 +19,7 @@ export class CustomersService { @Component({ standalone: true, imports: [AsyncPipe, NgForOf], - selector: 'app-fixture', + selector: 'atl-fixture', template: `

  • diff --git a/apps/example-app/src/app/examples/13-scrolling.component.ts b/apps/example-app/src/app/examples/13-scrolling.component.ts index 7d7b2e7..6a36ed8 100644 --- a/apps/example-app/src/app/examples/13-scrolling.component.ts +++ b/apps/example-app/src/app/examples/13-scrolling.component.ts @@ -4,7 +4,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling'; @Component({ standalone: true, imports: [ScrollingModule], - selector: 'app-cdk-virtual-scroll-overview-example', + selector: 'atl-cdk-virtual-scroll-overview-example', template: `
    {{ item }}
    diff --git a/apps/example-app/src/app/examples/14-async-component.spec.ts b/apps/example-app/src/app/examples/14-async-component.spec.ts index b54740a..5cfd3e0 100644 --- a/apps/example-app/src/app/examples/14-async-component.spec.ts +++ b/apps/example-app/src/app/examples/14-async-component.spec.ts @@ -3,7 +3,6 @@ import { render, screen, fireEvent } from '@testing-library/angular'; import { AsyncComponent } from './14-async-component'; -// eslint-disable-next-line jest/no-disabled-tests test.skip('can use fakeAsync utilities', fakeAsync(async () => { await render(AsyncComponent); diff --git a/apps/example-app/src/app/examples/14-async-component.ts b/apps/example-app/src/app/examples/14-async-component.ts index f87732a..64d7aaa 100644 --- a/apps/example-app/src/app/examples/14-async-component.ts +++ b/apps/example-app/src/app/examples/14-async-component.ts @@ -6,7 +6,7 @@ import { delay, filter, mapTo } from 'rxjs/operators'; @Component({ standalone: true, imports: [AsyncPipe, NgIf], - selector: 'app-fixture', + selector: 'atl-fixture', template: `
    {{ data }}
    diff --git a/apps/example-app/src/app/examples/15-dialog.component.spec.ts b/apps/example-app/src/app/examples/15-dialog.component.spec.ts index 97ad829..017afdc 100644 --- a/apps/example-app/src/app/examples/15-dialog.component.spec.ts +++ b/apps/example-app/src/app/examples/15-dialog.component.spec.ts @@ -38,7 +38,7 @@ test('closes the dialog via the backdrop', async () => { const dialogTitleControl = await screen.findByRole('heading', { name: /dialog title/i }); expect(dialogTitleControl).toBeInTheDocument(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, testing-library/no-node-access + // eslint-disable-next-line testing-library/no-node-access await user.click(document.querySelector('.cdk-overlay-backdrop')!); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); diff --git a/apps/example-app/src/app/examples/15-dialog.component.ts b/apps/example-app/src/app/examples/15-dialog.component.ts index f6c8970..029ee64 100644 --- a/apps/example-app/src/app/examples/15-dialog.component.ts +++ b/apps/example-app/src/app/examples/15-dialog.component.ts @@ -4,7 +4,7 @@ import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dial @Component({ standalone: true, imports: [MatDialogModule], - selector: 'app-dialog-overview-example', + selector: 'atl-dialog-overview-example', template: '', }) export class DialogComponent { @@ -18,7 +18,7 @@ export class DialogComponent { @Component({ standalone: true, imports: [MatDialogModule], - selector: 'app-dialog-overview-example-dialog', + selector: 'atl-dialog-overview-example-dialog', template: `

    Dialog Title

    Dialog content
    diff --git a/apps/example-app/src/app/examples/16-input-getter-setter.ts b/apps/example-app/src/app/examples/16-input-getter-setter.ts index a9097a4..4c18900 100644 --- a/apps/example-app/src/app/examples/16-input-getter-setter.ts +++ b/apps/example-app/src/app/examples/16-input-getter-setter.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: ` {{ derivedValue }} {{ value }} diff --git a/apps/example-app/src/app/examples/17-component-with-attribute-selector.spec.ts b/apps/example-app/src/app/examples/17-component-with-attribute-selector.spec.ts index ba69f70..f33dee3 100644 --- a/apps/example-app/src/app/examples/17-component-with-attribute-selector.spec.ts +++ b/apps/example-app/src/app/examples/17-component-with-attribute-selector.spec.ts @@ -5,7 +5,7 @@ import { ComponentWithAttributeSelectorComponent } from './17-component-with-att // for components with attribute selectors! test('is possible to set input of component with attribute selector through template', async () => { await render( - ``, + ``, { imports: [ComponentWithAttributeSelectorComponent], }, diff --git a/apps/example-app/src/app/examples/17-component-with-attribute-selector.ts b/apps/example-app/src/app/examples/17-component-with-attribute-selector.ts index ac2a25d..930032c 100644 --- a/apps/example-app/src/app/examples/17-component-with-attribute-selector.ts +++ b/apps/example-app/src/app/examples/17-component-with-attribute-selector.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; @Component({ standalone: true, - selector: 'app-fixture-component-with-attribute-selector[value]', + selector: 'atl-fixture-component-with-attribute-selector[value]', template: ` {{ value }} `, }) export class ComponentWithAttributeSelectorComponent { diff --git a/apps/example-app/src/app/examples/19-standalone-component.ts b/apps/example-app/src/app/examples/19-standalone-component.ts index efcf088..95eae3d 100644 --- a/apps/example-app/src/app/examples/19-standalone-component.ts +++ b/apps/example-app/src/app/examples/19-standalone-component.ts @@ -1,17 +1,17 @@ import { Component, Input } from '@angular/core'; @Component({ - selector: 'app-standalone', + selector: 'atl-standalone', template: `
    Standalone Component
    `, standalone: true, }) export class StandaloneComponent {} @Component({ - selector: 'app-standalone-with-child', + selector: 'atl-standalone-with-child', template: `

    Hi {{ name }}

    This has a child

    - `, + `, standalone: true, imports: [StandaloneComponent], }) diff --git a/apps/example-app/src/app/examples/20-test-harness.spec.ts b/apps/example-app/src/app/examples/20-test-harness.spec.ts index 6e4d9e3..4a88a58 100644 --- a/apps/example-app/src/app/examples/20-test-harness.spec.ts +++ b/apps/example-app/src/app/examples/20-test-harness.spec.ts @@ -6,9 +6,8 @@ import userEvent from '@testing-library/user-event'; import { HarnessComponent } from './20-test-harness'; -// eslint-disable-next-line jest/no-disabled-tests test.skip('can be used with TestHarness', async () => { - const view = await render(``, { + const view = await render(``, { imports: [HarnessComponent], }); const loader = TestbedHarnessEnvironment.documentRootLoader(view.fixture); @@ -21,7 +20,6 @@ test.skip('can be used with TestHarness', async () => { expect(await snackbarHarness.getMessage()).toMatch(/Pizza Party!!!/i); }); -// eslint-disable-next-line jest/no-disabled-tests test.skip('can be used in combination with TestHarness', async () => { const user = userEvent.setup(); diff --git a/apps/example-app/src/app/examples/20-test-harness.ts b/apps/example-app/src/app/examples/20-test-harness.ts index 08d6afd..8e5e407 100644 --- a/apps/example-app/src/app/examples/20-test-harness.ts +++ b/apps/example-app/src/app/examples/20-test-harness.ts @@ -3,7 +3,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @Component({ - selector: 'app-harness', + selector: 'atl-harness', standalone: true, imports: [MatButtonModule, MatSnackBarModule], template: ` diff --git a/apps/example-app/src/app/examples/21-deferable-view.component.ts b/apps/example-app/src/app/examples/21-deferable-view.component.ts index ce47a58..7b66d85 100644 --- a/apps/example-app/src/app/examples/21-deferable-view.component.ts +++ b/apps/example-app/src/app/examples/21-deferable-view.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; @Component({ - selector: 'app-deferable-view-child', + selector: 'atl-deferable-view-child', template: `

    Hello from deferred child component

    `, standalone: true, }) @@ -10,7 +10,7 @@ export class DeferableViewChildComponent {} @Component({ template: ` @defer (on timer(2s)) { - + } @placeholder {

    Hello from placeholder

    } @loading { diff --git a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts index 470e639..355e8ae 100644 --- a/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts @@ -60,7 +60,7 @@ test('output emits a value', async () => { age: '45', }, on: { - submit: submitFn, + submitValue: submitFn, }, }); diff --git a/apps/example-app/src/app/examples/22-signal-inputs.component.ts b/apps/example-app/src/app/examples/22-signal-inputs.component.ts index dfe6bd0..27ed23b 100644 --- a/apps/example-app/src/app/examples/22-signal-inputs.component.ts +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.ts @@ -2,7 +2,7 @@ import { Component, computed, input, model, numberAttribute, output } from '@ang import { FormsModule } from '@angular/forms'; @Component({ - selector: 'app-signal-input', + selector: 'atl-signal-input', template: `
    {{ greetings() }} {{ name() }} of {{ age() }} years old
    {{ greetingMessage() }}
    @@ -18,11 +18,11 @@ export class SignalInputComponent { }); age = input.required({ transform: numberAttribute }); name = model.required(); - submit = output(); + submitValue = output(); greetingMessage = computed(() => `${this.greetings()} ${this.name()} of ${this.age()} years old`); submitName() { - this.submit.emit(this.name()); + this.submitValue.emit(this.name()); } } diff --git a/apps/example-app/src/test-setup.ts b/apps/example-app/src/test-setup.ts index 0da94a0..96bfd34 100644 --- a/apps/example-app/src/test-setup.ts +++ b/apps/example-app/src/test-setup.ts @@ -1,2 +1,4 @@ -import 'jest-preset-angular/setup-jest'; +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; import '@testing-library/jest-dom'; + +setupZoneTestEnv(); diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..9e951e7 --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,7 @@ +// @ts-check + +// TODO - https://github.com/nrwl/nx/issues/22576 + +/** @type {import('@typescript-eslint/utils/ts-eslint').FlatConfig.ConfigPromise} */ +const config = (async () => (await import('./eslint.config.mjs')).default)(); +module.exports = config; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..95e031a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,66 @@ +// @ts-check + +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import angular from "angular-eslint"; +import jestDom from 'eslint-plugin-jest-dom'; +import testingLibrary from 'eslint-plugin-testing-library'; + +export default tseslint.config( + { + files: ["**/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + rules: { + "@angular-eslint/directive-selector": [ + "error", + { + type: "attribute", + prefix: "atl", + style: "camelCase", + }, + ], + "@angular-eslint/component-selector": [ + "error", + { + type: "element", + prefix: "atl", + style: "kebab-case", + }, + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + // These are needed for test cases + "@angular-eslint/prefer-standalone": "off", + "@angular-eslint/no-input-rename": "off", + "@angular-eslint/no-input-rename": "off", + }, + }, + { + files: ["**/*.spec.ts"], + extends: [ + jestDom.configs["flat/recommended"], + testingLibrary.configs["flat/angular"], + ], + }, + { + files: ["**/*.html"], + extends: [ + ...angular.configs.templateRecommended, + ...angular.configs.templateAccessibility, + ], + rules: {}, + } +); diff --git a/nx.json b/nx.json index df534f7..a308e67 100644 --- a/nx.json +++ b/nx.json @@ -82,7 +82,7 @@ } }, "@nx/eslint:lint": { - "inputs": ["default", "{workspaceRoot}/.eslintrc.json"], + "inputs": ["default", "{workspaceRoot}/eslint.config.cjs"], "cache": true } }, @@ -96,7 +96,7 @@ "!{projectRoot}/karma.conf.js", "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", "!{projectRoot}/jest.config.[jt]s", - "!{projectRoot}/.eslintrc.json", + "!{projectRoot}/eslint.config.cjs", "!{projectRoot}/src/test-setup.[jt]s" ] }, diff --git a/package.json b/package.json index 16ac142..bbf0e3c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "nx run-many --target=build --projects=testing-library", "build:schematics": "tsc -p ./projects/testing-library/tsconfig.schematics.json", "test": "nx run-many --target=test --all --parallel=1", - "lint": "nx workspace-lint && nx lint", + "lint": "nx run-many --all --target=lint", "e2e": "nx e2e", "affected:apps": "nx affected:apps", "affected:libs": "nx affected:libs", @@ -40,7 +40,7 @@ "@nx/angular": "20.3.0", "@testing-library/dom": "^10.4.0", "rxjs": "7.8.0", - "tslib": "~2.3.1", + "tslib": "~2.8.1", "zone.js": "^0.15.0" }, "devDependencies": { @@ -52,10 +52,11 @@ "@angular-eslint/eslint-plugin-template": "19.0.2", "@angular-eslint/schematics": "19.0.2", "@angular-eslint/template-parser": "19.0.2", - "@angular/cli": "~19.0.0", + "@angular/cli": "~19.0.6", "@angular/compiler-cli": "19.0.1", "@angular/forms": "19.0.1", "@angular/language-service": "19.0.1", + "@eslint/eslintrc": "^2.1.1", "@nx/eslint": "20.3.0", "@nx/eslint-plugin": "20.3.0", "@nx/jest": "20.3.0", @@ -63,48 +64,45 @@ "@nx/plugin": "20.3.0", "@nx/workspace": "20.3.0", "@schematics/angular": "18.2.9", - "@testing-library/jasmine-dom": "^1.2.0", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/user-event": "^14.4.3", + "@testing-library/jasmine-dom": "^1.3.3", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.5.2", "@types/jasmine": "4.3.1", "@types/jest": "29.5.14", "@types/node": "22.10.1", - "@types/testing-library__jasmine-dom": "^1.3.0", - "@typescript-eslint/eslint-plugin": "7.16.0", - "@typescript-eslint/parser": "7.16.0", - "@typescript-eslint/utils": "^7.16.0", - "autoprefixer": "^10.4.0", - "cpy-cli": "^3.1.1", - "eslint": "8.57.0", - "eslint-config-prettier": "9.0.0", - "eslint-plugin-import": "~2.25.4", - "eslint-plugin-jasmine": "~4.1.3", - "eslint-plugin-jest": "^27.6.3", - "eslint-plugin-jest-dom": "~4.0.1", - "eslint-plugin-testing-library": "~5.0.1", + "@types/testing-library__jasmine-dom": "^1.3.4", + "@typescript-eslint/types": "^8.19.0", + "@typescript-eslint/utils": "^8.19.0", + "angular-eslint": "^19.0.2", + "autoprefixer": "^10.4.20", + "cpy-cli": "^5.0.0", + "eslint": "^9.8.0", + "eslint-plugin-jest-dom": "~5.5.0", + "eslint-plugin-testing-library": "~7.1.1", "jasmine-core": "4.2.0", "jasmine-spec-reporter": "7.0.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", "jest-preset-angular": "14.4.2", "karma": "6.4.0", - "karma-chrome-launcher": "^3.1.0", + "karma-chrome-launcher": "^3.2.0", "karma-coverage": "^2.2.1", "karma-jasmine": "5.1.0", "karma-jasmine-html-reporter": "2.0.0", - "lint-staged": "^12.1.6", + "lint-staged": "^15.3.0", "ng-mocks": "^14.13.1", "ng-packagr": "19.0.1", "nx": "20.3.0", - "postcss": "^8.4.5", + "postcss": "^8.4.49", "postcss-import": "14.1.0", "postcss-preset-env": "7.5.0", "postcss-url": "10.1.3", "prettier": "2.6.2", - "rimraf": "^3.0.2", - "semantic-release": "^18.0.0", + "rimraf": "^5.0.10", + "semantic-release": "^24.2.1", "ts-jest": "29.1.0", "ts-node": "10.9.1", - "typescript": "5.6.2" + "typescript": "5.6.2", + "typescript-eslint": "^8.19.0" } } diff --git a/projects/testing-library/.eslintrc.json b/projects/testing-library/.eslintrc.json deleted file mode 100644 index 5a9d690..0000000 --- a/projects/testing-library/.eslintrc.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "extends": "../../.eslintrc.json", - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts"], - "rules": { - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } - ] - } - }, - { - "files": ["*.ts"], - "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], - "parserOptions": { - "project": ["projects/testing-library/tsconfig.*?.json"] - }, - "rules": { - "@typescript-eslint/ban-ts-comment": "off", - "@angular-eslint/directive-selector": [ - "error", - { - "type": "attribute", - "prefix": "atl", - "style": "camelCase" - } - ], - "@angular-eslint/component-selector": [ - "error", - { - "type": "element", - "prefix": "atl", - "style": "kebab-case" - } - ] - } - }, - { - "files": ["*.spec.ts"], - "env": { - "jest": true - }, - "extends": ["plugin:jest/recommended", "plugin:jest/style", "plugin:jest-dom/recommended"], - "rules": { - "jest/consistent-test-it": ["error"], - "jest/expect-expect": "off" - } - }, - { - "files": ["*.html"], - "extends": ["plugin:@nx/angular-template"], - "rules": {} - } - ] -} diff --git a/projects/testing-library/eslint.config.cjs b/projects/testing-library/eslint.config.cjs new file mode 100644 index 0000000..9e951e7 --- /dev/null +++ b/projects/testing-library/eslint.config.cjs @@ -0,0 +1,7 @@ +// @ts-check + +// TODO - https://github.com/nrwl/nx/issues/22576 + +/** @type {import('@typescript-eslint/utils/ts-eslint').FlatConfig.ConfigPromise} */ +const config = (async () => (await import('./eslint.config.mjs')).default)(); +module.exports = config; diff --git a/projects/testing-library/eslint.config.mjs b/projects/testing-library/eslint.config.mjs new file mode 100644 index 0000000..8f627db --- /dev/null +++ b/projects/testing-library/eslint.config.mjs @@ -0,0 +1,8 @@ +// @ts-check + +import tseslint from "typescript-eslint"; +import rootConfig from "../../eslint.config.mjs"; + +export default tseslint.config( + ...rootConfig, +); diff --git a/projects/testing-library/jest.config.ts b/projects/testing-library/jest.config.ts index 189e52f..bc5a665 100644 --- a/projects/testing-library/jest.config.ts +++ b/projects/testing-library/jest.config.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ export default { displayName: { name: 'ATL', diff --git a/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts index a3c0fd1..ebc3922 100644 --- a/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts +++ b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts @@ -16,7 +16,6 @@ test('adds DTL to devDependencies', async () => { }); test('ignores if DTL is already listed as a dev dependency', async () => { - // eslint-disable-next-line @typescript-eslint/naming-convention const tree = await setup({ devDependencies: { '@testing-library/dom': '^9.0.0' } }); const pkg = tree.readContent('package.json'); @@ -24,7 +23,6 @@ test('ignores if DTL is already listed as a dev dependency', async () => { }); test('ignores if DTL is already listed as a dependency', async () => { - // eslint-disable-next-line @typescript-eslint/naming-convention const tree = await setup({ dependencies: { '@testing-library/dom': '^11.0.0' } }); const pkg = tree.readContent('package.json'); diff --git a/projects/testing-library/schematics/ng-add/schema.ts b/projects/testing-library/schematics/ng-add/schema.ts index dc14633..b0dcd22 100644 --- a/projects/testing-library/schematics/ng-add/schema.ts +++ b/projects/testing-library/schematics/ng-add/schema.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Schema { installJestDom: boolean; installUserEvent: boolean; diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 0c34aa8..159ac41 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -243,7 +243,7 @@ export interface RenderComponentOptions | { [alias: string]: unknown }; + componentInputs?: Partial | Record; /** * @description @@ -466,7 +466,7 @@ export interface ComponentOverride { providers: any[]; } -// eslint-disable-next-line @typescript-eslint/ban-types +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface RenderTemplateOptions extends RenderComponentOptions { /** diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 2ddf389..f498a89 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -560,7 +560,7 @@ async function waitForWrapper( let inFakeAsync = true; try { tick(0); - } catch (err) { + } catch { inFakeAsync = false; } diff --git a/projects/testing-library/test-setup.ts b/projects/testing-library/test-setup.ts index 8d79c74..be311bf 100644 --- a/projects/testing-library/test-setup.ts +++ b/projects/testing-library/test-setup.ts @@ -4,5 +4,4 @@ import { TextEncoder, TextDecoder } from 'util'; setupZoneTestEnv(); -// eslint-disable-next-line @typescript-eslint/naming-convention Object.assign(global, { TextDecoder, TextEncoder }); diff --git a/projects/testing-library/tests/debug.spec.ts b/projects/testing-library/tests/debug.spec.ts index e1ad1df..63ab7e6 100644 --- a/projects/testing-library/tests/debug.spec.ts +++ b/projects/testing-library/tests/debug.spec.ts @@ -14,11 +14,11 @@ test('debug', async () => { jest.spyOn(console, 'log').mockImplementation(); const { debug } = await render(FixtureComponent); - // eslint-disable-next-line testing-library/no-debug + // eslint-disable-next-line testing-library/no-debugging-utils debug(); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('rawr')); - (console.log).mockRestore(); + (console.log as any).mockRestore(); }); test('debug allows to be called with an element', async () => { @@ -26,10 +26,10 @@ test('debug allows to be called with an element', async () => { const { debug } = await render(FixtureComponent); const btn = screen.getByTestId('btn'); - // eslint-disable-next-line testing-library/no-debug + // eslint-disable-next-line testing-library/no-debugging-utils debug(btn); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('rawr')); expect(console.log).toHaveBeenCalledWith(expect.stringContaining(`I'm a button`)); - (console.log).mockRestore(); + (console.log as any).mockRestore(); }); diff --git a/projects/testing-library/tests/issues/issue-280.spec.ts b/projects/testing-library/tests/issues/issue-280.spec.ts index 5e59534..711cbec 100644 --- a/projects/testing-library/tests/issues/issue-280.spec.ts +++ b/projects/testing-library/tests/issues/issue-280.spec.ts @@ -48,12 +48,12 @@ test('navigate to second page and back', async () => { expect(await screen.findByText('Navigate')).toBeInTheDocument(); expect(await screen.findByText('first page')).toBeInTheDocument(); - userEvent.click(await screen.findByText('go to second')); + await userEvent.click(await screen.findByText('go to second')); expect(await screen.findByText('second page')).toBeInTheDocument(); expect(await screen.findByText('navigate back')).toBeInTheDocument(); - userEvent.click(await screen.findByText('navigate back')); + await userEvent.click(await screen.findByText('navigate back')); expect(await screen.findByText('first page')).toBeInTheDocument(); }); diff --git a/projects/testing-library/tests/issues/issue-389.spec.ts b/projects/testing-library/tests/issues/issue-389.spec.ts index 03f25f7..626d388 100644 --- a/projects/testing-library/tests/issues/issue-389.spec.ts +++ b/projects/testing-library/tests/issues/issue-389.spec.ts @@ -6,7 +6,6 @@ import { render, screen } from '../../src/public_api'; template: `Hello {{ name }}`, }) class TestComponent { - // eslint-disable-next-line @angular-eslint/no-input-rename @Input('aliasName') name = ''; } diff --git a/projects/testing-library/tests/issues/issue-396-standalone-stub-child.spec.ts b/projects/testing-library/tests/issues/issue-396-standalone-stub-child.spec.ts index 2da43b3..7be9913 100644 --- a/projects/testing-library/tests/issues/issue-396-standalone-stub-child.spec.ts +++ b/projects/testing-library/tests/issues/issue-396-standalone-stub-child.spec.ts @@ -42,7 +42,6 @@ class ChildComponent {} selector: 'atl-child', template: `Hello from stub`, standalone: true, - // eslint-disable-next-line @angular-eslint/no-host-metadata-property, @typescript-eslint/naming-convention host: { 'collision-id': StubComponent.name }, }) class StubComponent {} diff --git a/projects/testing-library/tests/issues/issue-398-component-without-host-id.spec.ts b/projects/testing-library/tests/issues/issue-398-component-without-host-id.spec.ts index 4508d64..c775a2a 100644 --- a/projects/testing-library/tests/issues/issue-398-component-without-host-id.spec.ts +++ b/projects/testing-library/tests/issues/issue-398-component-without-host-id.spec.ts @@ -15,9 +15,7 @@ test('should re-create the app', async () => { selector: 'atl-fixture', standalone: true, template: '

    My title

    ', - // eslint-disable-next-line @angular-eslint/no-host-metadata-property host: { - // eslint-disable-next-line @typescript-eslint/naming-convention '[attr.id]': 'null', // this breaks the cleaning up of tests }, }) diff --git a/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts b/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts index 05e6e11..c4fa7a3 100644 --- a/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts +++ b/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts @@ -9,6 +9,7 @@ test('declaration specific dependencies should be available for components', asy template: `
    Test
    `, }) class TestComponent { + // eslint-disable-next-line @typescript-eslint/no-empty-function constructor(_elementRef: ElementRef) {} } diff --git a/projects/testing-library/tests/issues/issue-437.spec.ts b/projects/testing-library/tests/issues/issue-437.spec.ts index 2d0e7c5..dbf2506 100644 --- a/projects/testing-library/tests/issues/issue-437.spec.ts +++ b/projects/testing-library/tests/issues/issue-437.spec.ts @@ -24,7 +24,6 @@ test('issue #437', async () => { { imports: [MatSidenavModule] }, ); - // eslint-disable-next-line testing-library/prefer-explicit-assert await screen.findByTestId('test-button'); await user.click(screen.getByTestId('test-button')); @@ -51,7 +50,6 @@ test('issue #437 with fakeTimers', async () => { { imports: [MatSidenavModule] }, ); - // eslint-disable-next-line testing-library/prefer-explicit-assert await screen.findByTestId('test-button'); await user.click(screen.getByTestId('test-button')); diff --git a/projects/testing-library/tests/issues/issue-493.spec.ts b/projects/testing-library/tests/issues/issue-493.spec.ts index a49bc80..5d0e123 100644 --- a/projects/testing-library/tests/issues/issue-493.spec.ts +++ b/projects/testing-library/tests/issues/issue-493.spec.ts @@ -22,6 +22,6 @@ test('succeeds', async () => { }) class DummyComponent { value = input.required(); - // @ts-ignore + // @ts-expect-error http is unused but needed for the test constructor(private http: HttpClient) {} } diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 52d318c..dc54ac5 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -37,7 +37,7 @@ describe('DTL functionality', () => { it('creates queries and events', async () => { const view = await render(FixtureComponent); - /// We wish to test the utility function from `render` here. + // We wish to test the utility function from `render` here. // eslint-disable-next-line testing-library/prefer-screen-queries fireEvent.input(view.getByTestId('input'), { target: { value: 'a super awesome input' } }); // eslint-disable-next-line testing-library/prefer-screen-queries @@ -72,7 +72,6 @@ describe('component with child', () => { @Component({ selector: 'atl-child-fixture', template: `A mock child fixture`, - // eslint-disable-next-line @angular-eslint/no-host-metadata-property, @typescript-eslint/naming-convention host: { 'collision-id': MockChildFixtureComponent.name }, }) class MockChildFixtureComponent {} @@ -287,19 +286,19 @@ describe('on', () => { // eslint-disable-next-line @typescript-eslint/no-empty-function function _test(_on: OutputRefKeysWithCallback) {} - // @ts-expect-error + // @ts-expect-error wrong event type _test({ event: fnWithNumberArg }); _test({ event: fnWithVoidArg }); - // @ts-expect-error + // @ts-expect-error wrong event type _test({ event: fnWithNumberArg }); _test({ event: fnWithMouseEventArg }); - // @ts-expect-error + // @ts-expect-error wrong event type _test({ event: fnWithNumberArg }); _test({ event: fnWithStringArg }); - // @ts-expect-error + // @ts-expect-error wrong event type _test({ event: fnWithNumberArg }); _test({ event: fnWithMouseEventArg }); @@ -392,11 +391,11 @@ describe('Angular component life-cycle hooks', () => { const view = await render(FixtureWithNgOnChangesComponent, { componentProperties }); - /// We wish to test the utility function from `render` here. + // We wish to test the utility function from `render` here. // eslint-disable-next-line testing-library/prefer-screen-queries expect(view.getByText('Sarah')).toBeInTheDocument(); expect(nameChanged).toHaveBeenCalledWith('Sarah', true); - /// expect `nameChanged` to be called before `nameInitialized` + // expect `nameChanged` to be called before `nameInitialized` expect(nameChanged.mock.invocationCallOrder[0]).toBeLessThan(nameInitialized.mock.invocationCallOrder[0]); expect(nameChanged).toHaveBeenCalledTimes(1); }); @@ -408,11 +407,11 @@ describe('Angular component life-cycle hooks', () => { const view = await render(FixtureWithNgOnChangesComponent, { componentInputs: componentInput }); - /// We wish to test the utility function from `render` here. + // We wish to test the utility function from `render` here. // eslint-disable-next-line testing-library/prefer-screen-queries expect(view.getByText('Sarah')).toBeInTheDocument(); expect(nameChanged).toHaveBeenCalledWith('Sarah', true); - /// expect `nameChanged` to be called before `nameInitialized` + // expect `nameChanged` to be called before `nameInitialized` expect(nameChanged.mock.invocationCallOrder[0]).toBeLessThan(nameInitialized.mock.invocationCallOrder[0]); expect(nameChanged).toHaveBeenCalledTimes(1); }); From a18f647f310fa5fd09b09a40bb6a6001f66173f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabien=20Dehopr=C3=A9?= Date: Mon, 13 Jan 2025 18:23:09 +0100 Subject: [PATCH 28/42] fix: do not force npm to install dependencies when using `ng add` (#515) Closes #513 --- projects/testing-library/schematics/ng-add/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/testing-library/schematics/ng-add/index.ts b/projects/testing-library/schematics/ng-add/index.ts index d961e15..868d203 100644 --- a/projects/testing-library/schematics/ng-add/index.ts +++ b/projects/testing-library/schematics/ng-add/index.ts @@ -32,9 +32,9 @@ function addDependency(packageName: string, version: string, dependencyType: Nod }; } -export function installDependencies(packageManager = 'npm') { +export function installDependencies() { return (_tree: Tree, context: SchematicContext) => { - context.addTask(new NodePackageInstallTask({ packageManager })); + context.addTask(new NodePackageInstallTask()); context.logger.info( `Correctly installed @testing-library/angular. From 6efeb36bd8d6bfef53257d0396bd6b01b606b082 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:24:11 +0100 Subject: [PATCH 29/42] docs: add host directive example (#514) Closes #512 --- .../app/examples/23-host-directive.spec.ts | 22 +++++++++++++++++++ .../src/app/examples/23-host-directive.ts | 21 ++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 apps/example-app/src/app/examples/23-host-directive.spec.ts create mode 100644 apps/example-app/src/app/examples/23-host-directive.ts diff --git a/apps/example-app/src/app/examples/23-host-directive.spec.ts b/apps/example-app/src/app/examples/23-host-directive.spec.ts new file mode 100644 index 0000000..3289299 --- /dev/null +++ b/apps/example-app/src/app/examples/23-host-directive.spec.ts @@ -0,0 +1,22 @@ +import { aliasedInput, render, screen } from '@testing-library/angular'; +import { HostDirectiveComponent } from './23-host-directive'; + +test('can set input properties of host directives using aliasedInput', async () => { + await render(HostDirectiveComponent, { + inputs: { + ...aliasedInput('atlText', 'Hello world'), + }, + }); + + expect(screen.getByText(/hello world/i)).toBeInTheDocument(); +}); + +test('can set input properties of host directives using componentInputs', async () => { + await render(HostDirectiveComponent, { + componentInputs: { + atlText: 'Hello world', + }, + }); + + expect(screen.getByText(/hello world/i)).toBeInTheDocument(); +}); diff --git a/apps/example-app/src/app/examples/23-host-directive.ts b/apps/example-app/src/app/examples/23-host-directive.ts new file mode 100644 index 0000000..3e201c7 --- /dev/null +++ b/apps/example-app/src/app/examples/23-host-directive.ts @@ -0,0 +1,21 @@ +import { Component, Directive, ElementRef, input, OnInit } from '@angular/core'; + +@Directive({ + selector: '[atlText]', +}) +export class TextDirective implements OnInit { + atlText = input(''); + + constructor(private el: ElementRef) {} + + ngOnInit() { + this.el.nativeElement.textContent = this.atlText(); + } +} + +@Component({ + selector: 'atl-host-directive', + template: ``, + hostDirectives: [{ directive: TextDirective, inputs: ['atlText'] }], +}) +export class HostDirectiveComponent {} From 90c43c72962e4353ed7eeb618ed661385b4ef616 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:24:40 +0100 Subject: [PATCH 30/42] docs: add FabienDehopre as a contributor for code (#516) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3b3a470..03c629e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -419,6 +419,15 @@ "contributions": [ "code" ] + }, + { + "login": "FabienDehopre", + "name": "Fabien Dehopré", + "avatar_url": "https://avatars.githubusercontent.com/u/97023?v=4", + "profile": "https://github.com/FabienDehopre", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index e03ce68..3ac1d10 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ Thanks goes to these people ([emoji key][emojis]): Arthur Petrie
    Arthur Petrie

    💻 + Fabien Dehopré
    Fabien Dehopré

    💻 From 31ad7ceba8e79d9c921d6f0800fa6cb7629924f0 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Fri, 14 Feb 2025 09:06:44 +0100 Subject: [PATCH 31/42] chore: fix devcontainer creation (#518) --- .devcontainer/devcontainer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 72780fc..ac9d248 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ // For format details, see https://aka.ms/devcontainer.json. { "name": "angular-testing-library", - "image": "mcr.microsoft.com/devcontainers/typescript-node:0-22-bullseye", + "image": "mcr.microsoft.com/devcontainers/typescript-node:22-bullseye", // Features to add to the dev container. More info: https://containers.dev/features. "features": { @@ -13,7 +13,7 @@ // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "npm i", + "postCreateCommand": "npm install --force", "onCreateCommand": "sudo cp .devcontainer/welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt", "waitFor": "postCreateCommand", From 3176b3312143a32c693bcce2fc624bdc0c154307 Mon Sep 17 00:00:00 2001 From: Jamie Vereecken <108937550+jvereecken@users.noreply.github.com> Date: Mon, 31 Mar 2025 13:53:43 +0200 Subject: [PATCH 32/42] fix: add @angular/animations as a peer dependency (#522) Closes #519 --- projects/testing-library/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/testing-library/package.json b/projects/testing-library/package.json index 2852d02..0c3abd6 100644 --- a/projects/testing-library/package.json +++ b/projects/testing-library/package.json @@ -29,6 +29,7 @@ "migrations": "./schematics/migrations/migrations.json" }, "peerDependencies": { + "@angular/animations": ">= 17.0.0", "@angular/common": ">= 17.0.0", "@angular/platform-browser": ">= 17.0.0", "@angular/router": ">= 17.0.0", From e1e046c75c297fd8ab06237178d036553b1ff215 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 14:02:24 +0200 Subject: [PATCH 33/42] docs: add jvereecken as a contributor for code (#523) --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 03c629e..07a9a93 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -428,6 +428,15 @@ "contributions": [ "code" ] + }, + { + "login": "jvereecken", + "name": "Jamie Vereecken", + "avatar_url": "https://avatars.githubusercontent.com/u/108937550?v=4", + "profile": "https://github.com/jvereecken", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 3ac1d10..028f721 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ Thanks goes to these people ([emoji key][emojis]): Arthur Petrie
    Arthur Petrie

    💻 Fabien Dehopré
    Fabien Dehopré

    💻 + Jamie Vereecken
    Jamie Vereecken

    💻 From 5729aa962022360eceb9b9231f3e57b7d50af6ae Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Wed, 4 Jun 2025 08:21:14 +0200 Subject: [PATCH 34/42] chore: update nx (#529) --- .gitignore | 2 + apps/example-app-karma/project.json | 3 +- apps/example-app/project.json | 3 +- package.json | 66 ++++++++++++++--------------- 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index d16a75b..1204690 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ yarn.lock # System Files .DS_Store Thumbs.db +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md diff --git a/apps/example-app-karma/project.json b/apps/example-app-karma/project.json index 00820e3..27c4cbd 100644 --- a/apps/example-app-karma/project.json +++ b/apps/example-app-karma/project.json @@ -50,7 +50,8 @@ "buildTarget": "example-app-karma:build:development" } }, - "defaultConfiguration": "development" + "defaultConfiguration": "development", + "continuous": true }, "lint": { "executor": "@nx/eslint:lint" diff --git a/apps/example-app/project.json b/apps/example-app/project.json index ecbadfc..1cf90ac 100644 --- a/apps/example-app/project.json +++ b/apps/example-app/project.json @@ -51,7 +51,8 @@ "buildTarget": "example-app:build:development" } }, - "defaultConfiguration": "development" + "defaultConfiguration": "development", + "continuous": true }, "extract-i18n": { "executor": "@angular-devkit/build-angular:extract-i18n", diff --git a/package.json b/package.json index bbf0e3c..b3f540b 100644 --- a/package.json +++ b/package.json @@ -27,43 +27,43 @@ "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "19.0.1", - "@angular/cdk": "19.0.1", - "@angular/common": "19.0.1", - "@angular/compiler": "19.0.1", - "@angular/core": "19.0.1", - "@angular/material": "19.0.1", - "@angular/platform-browser": "19.0.1", - "@angular/platform-browser-dynamic": "19.0.1", - "@angular/router": "19.0.1", + "@angular/animations": "19.2.14", + "@angular/cdk": "19.2.18", + "@angular/common": "19.2.14", + "@angular/compiler": "19.2.14", + "@angular/core": "19.2.14", + "@angular/material": "19.2.18", + "@angular/platform-browser": "19.2.14", + "@angular/platform-browser-dynamic": "19.2.14", + "@angular/router": "19.2.14", "@ngrx/store": "19.0.0", - "@nx/angular": "20.3.0", + "@nx/angular": "21.1.2", "@testing-library/dom": "^10.4.0", "rxjs": "7.8.0", "tslib": "~2.8.1", "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "19.0.1", - "@angular-devkit/core": "19.0.1", - "@angular-devkit/schematics": "19.0.1", - "@angular-eslint/builder": "19.0.2", - "@angular-eslint/eslint-plugin": "19.0.2", - "@angular-eslint/eslint-plugin-template": "19.0.2", - "@angular-eslint/schematics": "19.0.2", - "@angular-eslint/template-parser": "19.0.2", - "@angular/cli": "~19.0.6", - "@angular/compiler-cli": "19.0.1", - "@angular/forms": "19.0.1", - "@angular/language-service": "19.0.1", + "@angular-devkit/build-angular": "19.2.9", + "@angular-devkit/core": "19.2.9", + "@angular-devkit/schematics": "19.2.9", + "@angular-eslint/builder": "19.2.0", + "@angular-eslint/eslint-plugin": "19.2.0", + "@angular-eslint/eslint-plugin-template": "19.2.0", + "@angular-eslint/schematics": "19.2.0", + "@angular-eslint/template-parser": "19.2.0", + "@angular/cli": "~19.2.0", + "@angular/compiler-cli": "19.2.14", + "@angular/forms": "19.2.14", + "@angular/language-service": "19.2.14", "@eslint/eslintrc": "^2.1.1", - "@nx/eslint": "20.3.0", - "@nx/eslint-plugin": "20.3.0", - "@nx/jest": "20.3.0", - "@nx/node": "20.3.0", - "@nx/plugin": "20.3.0", - "@nx/workspace": "20.3.0", - "@schematics/angular": "18.2.9", + "@nx/eslint": "21.1.2", + "@nx/eslint-plugin": "21.1.2", + "@nx/jest": "21.1.2", + "@nx/node": "21.1.2", + "@nx/plugin": "21.1.2", + "@nx/workspace": "21.1.2", + "@schematics/angular": "19.2.9", "@testing-library/jasmine-dom": "^1.3.3", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.5.2", @@ -73,7 +73,7 @@ "@types/testing-library__jasmine-dom": "^1.3.4", "@typescript-eslint/types": "^8.19.0", "@typescript-eslint/utils": "^8.19.0", - "angular-eslint": "^19.0.2", + "angular-eslint": "19.2.0", "autoprefixer": "^10.4.20", "cpy-cli": "^5.0.0", "eslint": "^9.8.0", @@ -91,8 +91,8 @@ "karma-jasmine-html-reporter": "2.0.0", "lint-staged": "^15.3.0", "ng-mocks": "^14.13.1", - "ng-packagr": "19.0.1", - "nx": "20.3.0", + "ng-packagr": "19.2.2", + "nx": "21.1.2", "postcss": "^8.4.49", "postcss-import": "14.1.0", "postcss-preset-env": "7.5.0", @@ -102,7 +102,7 @@ "semantic-release": "^24.2.1", "ts-jest": "29.1.0", "ts-node": "10.9.1", - "typescript": "5.6.2", + "typescript": "5.7.3", "typescript-eslint": "^8.19.0" } } From 3cb2469f3d550dc3f4ca4584c90e6efe2dbe6c51 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Fri, 6 Jun 2025 15:40:13 +0200 Subject: [PATCH 35/42] feat: undeprecate componentProperties (#520) --- projects/testing-library/src/lib/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 159ac41..47ea5bb 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -215,7 +215,7 @@ export interface RenderComponentOptions Date: Mon, 30 Jun 2025 07:51:08 +0200 Subject: [PATCH 36/42] docs: add Christian24 as a contributor for code, and review (#534) --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 07a9a93..51b2324 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -437,6 +437,16 @@ "contributions": [ "code" ] + }, + { + "login": "Christian24", + "name": "Christian24", + "avatar_url": "https://avatars.githubusercontent.com/u/2406635?v=4", + "profile": "https://github.com/Christian24", + "contributions": [ + "code", + "review" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 028f721..1591a67 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ Thanks goes to these people ([emoji key][emojis]): Arthur Petrie
    Arthur Petrie

    💻 Fabien Dehopré
    Fabien Dehopré

    💻 Jamie Vereecken
    Jamie Vereecken

    💻 + Christian24
    Christian24

    💻 👀 From 420f0d3bdfd9eb0e41a0b41367284ce1495b53f0 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:43:13 +0200 Subject: [PATCH 37/42] docs: add mikeshtro as a contributor for code, and bug (#537) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 51b2324..cc7f5ba 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -447,6 +447,16 @@ "code", "review" ] + }, + { + "login": "mikeshtro", + "name": "Michal Štrajt", + "avatar_url": "https://avatars.githubusercontent.com/u/93714867?v=4", + "profile": "https://github.com/mikeshtro", + "contributions": [ + "code", + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 1591a67..9ab0272 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ Thanks goes to these people ([emoji key][emojis]): Fabien Dehopré
    Fabien Dehopré

    💻 Jamie Vereecken
    Jamie Vereecken

    💻 Christian24
    Christian24

    💻 👀 + Michal Štrajt
    Michal Štrajt

    💻 🐛 From 4fea701f92661c9758d3895b71bb6be3255caabc Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Thu, 7 Aug 2025 18:04:32 +0200 Subject: [PATCH 38/42] feat: release v18 (#533) * feat: update to Angular 20 (#530) BREAKING CHANGE: The angular minimum version has changed. BEFORE: Angular 17,18,19 were supported. AFTER: Angular 20 (and up) is supported. Reason: The method `TestBed.get` has been removed. * feat: remove animations dependency (#531) BREAKING CHANGE: Angular recommends using CSS animations, https://angular.dev/guide/animations/migration Because of the removal of the animations dependency, the `NoopAnimationsModule` is no longer automatically imported in the render function. BEFORE: The `NoopAnimationsModule` was always imported to the render the component. AFTER: Import the `NoopAnimationsModule` in your render configuration (where needed): ```ts await render(SutComponent, { imports: [NoopAnimationsModule], }); ``` * feat: add stronger types (#413) --- .github/workflows/ci.yml | 2 +- .gitignore | 1 + README.md | 19 +++++----- .../src/app/issues/issue-491.spec.ts | 4 +- .../app/examples/15-dialog.component.spec.ts | 10 ++++- package.json | 38 +++++++++---------- projects/testing-library/package.json | 9 ++--- projects/testing-library/src/lib/config.ts | 3 -- projects/testing-library/src/lib/models.ts | 28 +++++++++----- .../src/lib/testing-library.ts | 23 ++++------- .../tests/defer-blocks.spec.ts | 1 - projects/testing-library/tests/render.spec.ts | 20 ---------- 12 files changed, 70 insertions(+), 88 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 215ef7d..b35c5ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: - node-version: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '[22]' || '[18, 20, 22]') }} + node-version: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '[22]' || '[20, 22, 24]') }} os: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '["ubuntu-latest"]' || '["ubuntu-latest", "windows-latest"]') }} runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 1204690..22faaca 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ yarn.lock Thumbs.db .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md +.history diff --git a/README.md b/README.md index 9ab0272..d9d16a3 100644 --- a/README.md +++ b/README.md @@ -177,15 +177,16 @@ You may also be interested in installing `jest-dom` so you can use ## Version compatibility -| Angular | Angular Testing Library | -| ------- | ---------------------------- | -| 19.x | 17.x, 16.x, 15.x, 14.x, 13.x | -| 18.x | 17.x, 16.x, 15.x, 14.x, 13.x | -| 17.x | 17.x, 16.x, 15.x, 14.x, 13.x | -| 16.x | 14.x, 13.x | -| >= 15.1 | 14.x, 13.x | -| < 15.1 | 12.x, 11.x | -| 14.x | 12.x, 11.x | +| Angular | Angular Testing Library | +| ------- | ---------------------------------- | +| 20.x | 18.x, 17.x, 16.x, 15.x, 14.x, 13.x | +| 19.x | 17.x, 16.x, 15.x, 14.x, 13.x | +| 18.x | 17.x, 16.x, 15.x, 14.x, 13.x | +| 17.x | 17.x, 16.x, 15.x, 14.x, 13.x | +| 16.x | 14.x, 13.x | +| >= 15.1 | 14.x, 13.x | +| < 15.1 | 12.x, 11.x | +| 14.x | 12.x, 11.x | ## Guiding Principles diff --git a/apps/example-app-karma/src/app/issues/issue-491.spec.ts b/apps/example-app-karma/src/app/issues/issue-491.spec.ts index 7da4d6d..9320251 100644 --- a/apps/example-app-karma/src/app/issues/issue-491.spec.ts +++ b/apps/example-app-karma/src/app/issues/issue-491.spec.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; -import { render, screen, waitForElementToBeRemoved } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; it('test click event with router.navigate', async () => { @@ -31,8 +31,6 @@ it('test click event with router.navigate', async () => { await user.click(screen.getByRole('button', { name: 'submit' })); - await waitForElementToBeRemoved(() => screen.queryByRole('heading', { name: 'Login' })); - expect(await screen.findByRole('heading', { name: 'Logged In' })).toBeVisible(); }); diff --git a/apps/example-app/src/app/examples/15-dialog.component.spec.ts b/apps/example-app/src/app/examples/15-dialog.component.spec.ts index 017afdc..df172be 100644 --- a/apps/example-app/src/app/examples/15-dialog.component.spec.ts +++ b/apps/example-app/src/app/examples/15-dialog.component.spec.ts @@ -1,4 +1,5 @@ import { MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { render, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; @@ -9,6 +10,7 @@ test('dialog closes', async () => { const closeFn = jest.fn(); await render(DialogContentComponent, { + imports: [NoopAnimationsModule], providers: [ { provide: MatDialogRef, @@ -28,7 +30,9 @@ test('dialog closes', async () => { test('closes the dialog via the backdrop', async () => { const user = userEvent.setup(); - await render(DialogComponent); + await render(DialogComponent, { + imports: [NoopAnimationsModule], + }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); await user.click(openDialogButton); @@ -50,7 +54,9 @@ test('closes the dialog via the backdrop', async () => { test('opens and closes the dialog with buttons', async () => { const user = userEvent.setup(); - await render(DialogComponent); + await render(DialogComponent, { + imports: [NoopAnimationsModule], + }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); await user.click(openDialogButton); diff --git a/package.json b/package.json index b3f540b..3888395 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,15 @@ "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "19.2.14", - "@angular/cdk": "19.2.18", - "@angular/common": "19.2.14", - "@angular/compiler": "19.2.14", - "@angular/core": "19.2.14", - "@angular/material": "19.2.18", - "@angular/platform-browser": "19.2.14", - "@angular/platform-browser-dynamic": "19.2.14", - "@angular/router": "19.2.14", + "@angular/animations": "20.0.0", + "@angular/cdk": "20.0.0", + "@angular/common": "20.0.0", + "@angular/compiler": "20.0.0", + "@angular/core": "20.0.0", + "@angular/material": "20.0.0", + "@angular/platform-browser": "20.0.0", + "@angular/platform-browser-dynamic": "20.0.0", + "@angular/router": "20.0.0", "@ngrx/store": "19.0.0", "@nx/angular": "21.1.2", "@testing-library/dom": "^10.4.0", @@ -44,18 +44,18 @@ "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "19.2.9", - "@angular-devkit/core": "19.2.9", - "@angular-devkit/schematics": "19.2.9", + "@angular-devkit/build-angular": "20.0.0", + "@angular-devkit/core": "20.0.0", + "@angular-devkit/schematics": "20.0.0", "@angular-eslint/builder": "19.2.0", "@angular-eslint/eslint-plugin": "19.2.0", "@angular-eslint/eslint-plugin-template": "19.2.0", "@angular-eslint/schematics": "19.2.0", "@angular-eslint/template-parser": "19.2.0", - "@angular/cli": "~19.2.0", - "@angular/compiler-cli": "19.2.14", - "@angular/forms": "19.2.14", - "@angular/language-service": "19.2.14", + "@angular/cli": "~20.0.0", + "@angular/compiler-cli": "20.0.0", + "@angular/forms": "20.0.0", + "@angular/language-service": "20.0.0", "@eslint/eslintrc": "^2.1.1", "@nx/eslint": "21.1.2", "@nx/eslint-plugin": "21.1.2", @@ -63,7 +63,7 @@ "@nx/node": "21.1.2", "@nx/plugin": "21.1.2", "@nx/workspace": "21.1.2", - "@schematics/angular": "19.2.9", + "@schematics/angular": "20.0.0", "@testing-library/jasmine-dom": "^1.3.3", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.5.2", @@ -91,7 +91,7 @@ "karma-jasmine-html-reporter": "2.0.0", "lint-staged": "^15.3.0", "ng-mocks": "^14.13.1", - "ng-packagr": "19.2.2", + "ng-packagr": "20.0.0", "nx": "21.1.2", "postcss": "^8.4.49", "postcss-import": "14.1.0", @@ -102,7 +102,7 @@ "semantic-release": "^24.2.1", "ts-jest": "29.1.0", "ts-node": "10.9.1", - "typescript": "5.7.3", + "typescript": "5.8.2", "typescript-eslint": "^8.19.0" } } diff --git a/projects/testing-library/package.json b/projects/testing-library/package.json index 0c3abd6..6ea1a38 100644 --- a/projects/testing-library/package.json +++ b/projects/testing-library/package.json @@ -29,11 +29,10 @@ "migrations": "./schematics/migrations/migrations.json" }, "peerDependencies": { - "@angular/animations": ">= 17.0.0", - "@angular/common": ">= 17.0.0", - "@angular/platform-browser": ">= 17.0.0", - "@angular/router": ">= 17.0.0", - "@angular/core": ">= 17.0.0", + "@angular/common": ">= 20.0.0", + "@angular/platform-browser": ">= 20.0.0", + "@angular/router": ">= 20.0.0", + "@angular/core": ">= 20.0.0", "@testing-library/dom": "^10.0.0" }, "dependencies": { diff --git a/projects/testing-library/src/lib/config.ts b/projects/testing-library/src/lib/config.ts index bd8ee9b..075c91c 100644 --- a/projects/testing-library/src/lib/config.ts +++ b/projects/testing-library/src/lib/config.ts @@ -7,12 +7,9 @@ let config: Config = { export function configure(newConfig: Partial | ((config: Partial) => Partial)) { if (typeof newConfig === 'function') { - // Pass the existing config out to the provided function - // and accept a delta in return newConfig = newConfig(config); } - // Merge the incoming config delta config = { ...config, ...newConfig, diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 47ea5bb..318bd2b 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,4 +1,13 @@ -import { Type, DebugElement, EventEmitter, Signal, InputSignalWithTransform } from '@angular/core'; +import { + Type, + DebugElement, + ModuleWithProviders, + EventEmitter, + EnvironmentProviders, + Provider, + Signal, + InputSignalWithTransform, +} from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; @@ -153,7 +162,7 @@ export interface RenderComponentOptions | unknown[])[]; /** * @description * A collection of providers needed to render the component via Dependency Injection, for example, injectable services or tokens. @@ -174,16 +183,15 @@ export interface RenderComponentOptions | ModuleWithProviders)[]; /** * @description * A collection of schemas needed to render the component. @@ -315,7 +323,7 @@ export interface RenderComponentOptions | any[])[]; + componentImports?: (Type | unknown[])[]; /** * @description * Queries to bind. Overrides the default set from DOM Testing Library unless merged. @@ -463,7 +471,7 @@ export interface RenderComponentOptions { component: Type; - providers: any[]; + providers: Provider[]; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -497,5 +505,5 @@ export interface Config extends Pick, 'excludeCompon /** * Imports that are added to the imports */ - defaultImports: any[]; + defaultImports?: (Type | ModuleWithProviders)[]; } diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index f498a89..4667727 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -6,13 +6,13 @@ import { OnChanges, OutputRef, OutputRefSubscription, + Provider, SimpleChange, SimpleChanges, Type, isStandalone, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing'; -import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NavigationExtras, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import type { BoundFunctions, Queries } from '@testing-library/dom'; @@ -40,7 +40,6 @@ import { type SubscribedOutput = readonly [key: keyof T, callback: (v: any) => void, subscription: OutputRefSubscription]; const mountedFixtures = new Set>(); -const safeInject = TestBed.inject || TestBed.get; export async function render( component: Type, @@ -108,7 +107,7 @@ export async function render( imports: imports.concat(defaultImports), routes, }), - providers: [...providers], + providers, schemas: [...schemas], deferBlockBehavior: deferBlockBehavior ?? DeferBlockBehavior.Manual, }); @@ -126,8 +125,8 @@ export async function render( const componentContainer = createComponentFixture(sut, wrapper); - const zone = safeInject(NgZone); - const router = safeInject(Router); + const zone = TestBed.inject(NgZone); + const router = TestBed.inject(Router); const _navigate = async (elementOrPath: Element | string, basePath = ''): Promise => { const href = typeof elementOrPath === 'string' ? elementOrPath : elementOrPath.getAttribute('href'); const [path, params] = (basePath + href).split('?'); @@ -338,7 +337,7 @@ export async function render( async function createComponent(component: Type): Promise> { /* Make sure angular application is initialized before creating component */ - await safeInject(ApplicationInitStatus).donePromise; + await TestBed.inject(ApplicationInitStatus).donePromise; return TestBed.createComponent(component); } @@ -435,7 +434,7 @@ function overrideComponentImports(sut: Type | string, imports: function overrideChildComponentProviders(componentOverrides: ComponentOverride[]) { if (componentOverrides) { for (const { component, providers } of componentOverrides) { - TestBed.overrideComponent(component, { set: { providers } }); + TestBed.overrideComponent(component, { set: { providers: providers as Provider[] } }); } } } @@ -498,7 +497,7 @@ function addAutoDeclarations( wrapper, }: Pick, 'declarations' | 'excludeComponentDeclaration' | 'wrapper'>, ) { - const nonStandaloneDeclarations = declarations?.filter((d) => !isStandalone(d)); + const nonStandaloneDeclarations = declarations.filter((d) => !isStandalone(d as Type)); if (typeof sut === 'string') { if (wrapper && isStandalone(wrapper)) { return nonStandaloneDeclarations; @@ -514,15 +513,9 @@ function addAutoImports( sut: Type | string, { imports = [], routes }: Pick, 'imports' | 'routes'>, ) { - const animations = () => { - const animationIsDefined = - imports.indexOf(NoopAnimationsModule) > -1 || imports.indexOf(BrowserAnimationsModule) > -1; - return animationIsDefined ? [] : [NoopAnimationsModule]; - }; - const routing = () => (routes ? [RouterTestingModule.withRoutes(routes)] : []); const components = () => (typeof sut !== 'string' && isStandalone(sut) ? [sut] : []); - return [...imports, ...components(), ...animations(), ...routing()]; + return [...imports, ...components(), ...routing()]; } async function renderDeferBlock( diff --git a/projects/testing-library/tests/defer-blocks.spec.ts b/projects/testing-library/tests/defer-blocks.spec.ts index 7405a4d..ffd5e95 100644 --- a/projects/testing-library/tests/defer-blocks.spec.ts +++ b/projects/testing-library/tests/defer-blocks.spec.ts @@ -33,7 +33,6 @@ test('renders a defer block in different states using DeferBlockBehavior.Playthr deferBlockBehavior: DeferBlockBehavior.Playthrough, }); - expect(await screen.findByText(/loading/i)).toBeInTheDocument(); expect(await screen.findByText(/Defer block content/i)).toBeInTheDocument(); }); diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index dc54ac5..a93da90 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -17,7 +17,6 @@ import { model, } from '@angular/core'; import { outputFromObservable } from '@angular/core/rxjs-interop'; -import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestBed } from '@angular/core/testing'; import { render, fireEvent, screen, OutputRefKeysWithCallback, aliasedInput } from '../src/public_api'; import { ActivatedRoute, Resolve, RouterModule } from '@angular/router'; @@ -331,25 +330,6 @@ describe('excludeComponentDeclaration', () => { }); }); -describe('animationModule', () => { - it('adds NoopAnimationsModule by default', async () => { - await render(FixtureComponent); - const noopAnimationsModule = TestBed.inject(NoopAnimationsModule); - expect(noopAnimationsModule).toBeDefined(); - }); - - it('does not add NoopAnimationsModule if BrowserAnimationsModule is an import', async () => { - await render(FixtureComponent, { - imports: [BrowserAnimationsModule], - }); - - const browserAnimationsModule = TestBed.inject(BrowserAnimationsModule); - expect(browserAnimationsModule).toBeDefined(); - - expect(() => TestBed.inject(NoopAnimationsModule)).toThrow(); - }); -}); - describe('Angular component life-cycle hooks', () => { @Component({ selector: 'atl-fixture', From e66514bd78cb3508944d018f015802de220ccad2 Mon Sep 17 00:00:00 2001 From: "J. Degand" <70610011+jdegand@users.noreply.github.com> Date: Thu, 4 Sep 2025 02:35:32 -0400 Subject: [PATCH 39/42] chore: remove duplicate eslint rule (#543) Closes #542 --- eslint.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 95e031a..f46f58b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,7 +45,6 @@ export default tseslint.config( // These are needed for test cases "@angular-eslint/prefer-standalone": "off", "@angular-eslint/no-input-rename": "off", - "@angular-eslint/no-input-rename": "off", }, }, { From 3e0172874c6e808325e522cf46a427b6bcca1880 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:17:45 +0200 Subject: [PATCH 40/42] docs: add jdegand as a contributor for code (#544) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index cc7f5ba..c60fd01 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -457,6 +457,15 @@ "code", "bug" ] + }, + { + "login": "jdegand", + "name": "J. Degand", + "avatar_url": "https://avatars.githubusercontent.com/u/70610011?v=4", + "profile": "https://github.com/jdegand", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index d9d16a3..848aed0 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ Thanks goes to these people ([emoji key][emojis]): Jamie Vereecken
    Jamie Vereecken

    💻 Christian24
    Christian24

    💻 👀 Michal Štrajt
    Michal Štrajt

    💻 🐛 + J. Degand
    J. Degand

    💻 From 5da958bef6a542b6305607221fe8435400a442d2 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:31:33 +0200 Subject: [PATCH 41/42] chore: update deps to v20 (#541) --- apps/example-app-karma/eslint.config.mjs | 8 +- .../src/app/examples/login-form.spec.ts | 6 +- .../src/app/issues/issue-491.spec.ts | 4 +- apps/example-app/eslint.config.mjs | 8 +- apps/example-app/src/app/examples/03-forms.ts | 6 +- .../app/examples/04-forms-with-material.ts | 6 +- .../src/app/examples/05-component-provider.ts | 4 +- .../src/app/examples/06-with-ngrx-store.ts | 5 +- .../app/examples/07-with-ngrx-mock-store.ts | 5 +- .../src/app/examples/08-directive.ts | 6 +- .../example-app/src/app/examples/09-router.ts | 4 +- .../examples/10-inject-token-dependency.ts | 4 +- .../src/app/examples/12-service-component.ts | 4 +- .../app/examples/15-dialog.component.spec.ts | 8 +- .../src/app/examples/15-dialog.component.ts | 6 +- .../app/examples/16-input-getter-setter.ts | 1 - .../src/app/examples/20-test-harness.ts | 4 +- .../src/app/examples/23-host-directive.ts | 5 +- eslint.config.mjs | 61 +++++++-------- package.json | 77 ++++++++++--------- projects/testing-library/eslint.config.mjs | 8 +- .../jest-utils/tests/create-mock.spec.ts | 4 +- projects/testing-library/tests/config.spec.ts | 5 +- .../testing-library/tests/integration.spec.ts | 6 +- .../tests/issues/issue-280.spec.ts | 4 +- .../tests/issues/issue-318.spec.ts | 21 +++-- ...irective-overrides-component-input.spec.ts | 7 +- .../issue-422-view-already-destroyed.spec.ts | 6 +- .../tests/issues/issue-435.spec.ts | 4 +- .../tests/issues/issue-493.spec.ts | 6 +- .../providers/component-provider.spec.ts | 4 +- .../tests/providers/module-provider.spec.ts | 4 +- .../tests/render-template.spec.ts | 8 +- projects/testing-library/tests/render.spec.ts | 4 +- 34 files changed, 154 insertions(+), 169 deletions(-) diff --git a/apps/example-app-karma/eslint.config.mjs b/apps/example-app-karma/eslint.config.mjs index 8f627db..bd9b42b 100644 --- a/apps/example-app-karma/eslint.config.mjs +++ b/apps/example-app-karma/eslint.config.mjs @@ -1,8 +1,6 @@ // @ts-check -import tseslint from "typescript-eslint"; -import rootConfig from "../../eslint.config.mjs"; +import tseslint from 'typescript-eslint'; +import rootConfig from '../../eslint.config.mjs'; -export default tseslint.config( - ...rootConfig, -); +export default tseslint.config(...rootConfig); diff --git a/apps/example-app-karma/src/app/examples/login-form.spec.ts b/apps/example-app-karma/src/app/examples/login-form.spec.ts index a028234..d019e06 100644 --- a/apps/example-app-karma/src/app/examples/login-form.spec.ts +++ b/apps/example-app-karma/src/app/examples/login-form.spec.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import userEvent from '@testing-library/user-event'; import { render, screen } from '@testing-library/angular'; @@ -45,13 +45,13 @@ it('should display invalid message and submit button must be disabled', async () `, }) class LoginComponent { + private fb = inject(FormBuilder); + form: FormGroup = this.fb.group({ email: ['', [Validators.required, Validators.email]], password: ['', [Validators.required, Validators.minLength(8)]], }); - constructor(private fb: FormBuilder) {} - get email(): FormControl { return this.form.get('email') as FormControl; } diff --git a/apps/example-app-karma/src/app/issues/issue-491.spec.ts b/apps/example-app-karma/src/app/issues/issue-491.spec.ts index 9320251..9c96771 100644 --- a/apps/example-app-karma/src/app/issues/issue-491.spec.ts +++ b/apps/example-app-karma/src/app/issues/issue-491.spec.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { render, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; @@ -43,7 +43,7 @@ it('test click event with router.navigate', async () => { `, }) class LoginComponent { - constructor(private router: Router) {} + private readonly router = inject(Router); onSubmit(): void { this.router.navigate(['logged-in']); } diff --git a/apps/example-app/eslint.config.mjs b/apps/example-app/eslint.config.mjs index 0162584..bd9b42b 100644 --- a/apps/example-app/eslint.config.mjs +++ b/apps/example-app/eslint.config.mjs @@ -1,8 +1,6 @@ // @ts-check -import tseslint from "typescript-eslint"; -import rootConfig from "../../eslint.config.mjs"; +import tseslint from 'typescript-eslint'; +import rootConfig from '../../eslint.config.mjs'; -export default tseslint.config( - ...rootConfig, -); \ No newline at end of file +export default tseslint.config(...rootConfig); diff --git a/apps/example-app/src/app/examples/03-forms.ts b/apps/example-app/src/app/examples/03-forms.ts index a62d865..c1e48c2 100644 --- a/apps/example-app/src/app/examples/03-forms.ts +++ b/apps/example-app/src/app/examples/03-forms.ts @@ -1,5 +1,5 @@ import { NgForOf, NgIf } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; @Component({ @@ -33,6 +33,8 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; `, }) export class FormsComponent { + private formBuilder = inject(FormBuilder); + colors = [ { id: 'R', value: 'Red' }, { id: 'B', value: 'Blue' }, @@ -45,8 +47,6 @@ export class FormsComponent { color: [null as string | null, Validators.required], }); - constructor(private formBuilder: FormBuilder) {} - get formErrors() { return Object.keys(this.form.controls) .map((formKey) => { diff --git a/apps/example-app/src/app/examples/04-forms-with-material.ts b/apps/example-app/src/app/examples/04-forms-with-material.ts index cf117a5..2376c72 100644 --- a/apps/example-app/src/app/examples/04-forms-with-material.ts +++ b/apps/example-app/src/app/examples/04-forms-with-material.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { NgForOf, NgIf } from '@angular/common'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -84,6 +84,8 @@ import { MatNativeDateModule } from '@angular/material/core'; ], }) export class MaterialFormsComponent { + private formBuilder = inject(FormBuilder); + colors = [ { id: 'R', value: 'Red' }, { id: 'B', value: 'Blue' }, @@ -97,8 +99,6 @@ export class MaterialFormsComponent { agree: [false, Validators.requiredTrue], }); - constructor(private formBuilder: FormBuilder) {} - get colorControlDisplayValue(): string | undefined { const selectedId = this.form.get('color')?.value; return this.colors.filter((color) => color.id === selectedId)[0]?.value; diff --git a/apps/example-app/src/app/examples/05-component-provider.ts b/apps/example-app/src/app/examples/05-component-provider.ts index 2d66b07..c6162e0 100644 --- a/apps/example-app/src/app/examples/05-component-provider.ts +++ b/apps/example-app/src/app/examples/05-component-provider.ts @@ -1,4 +1,4 @@ -import { Component, Injectable } from '@angular/core'; +import { Component, inject, Injectable } from '@angular/core'; @Injectable({ providedIn: 'root', @@ -30,5 +30,5 @@ export class CounterService { providers: [CounterService], }) export class ComponentWithProviderComponent { - constructor(public counter: CounterService) {} + protected counter = inject(CounterService); } diff --git a/apps/example-app/src/app/examples/06-with-ngrx-store.ts b/apps/example-app/src/app/examples/06-with-ngrx-store.ts index 8702843..f478e52 100644 --- a/apps/example-app/src/app/examples/06-with-ngrx-store.ts +++ b/apps/example-app/src/app/examples/06-with-ngrx-store.ts @@ -1,5 +1,5 @@ import { AsyncPipe } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { createSelector, Store, createAction, createReducer, on, select } from '@ngrx/store'; const increment = createAction('increment'); @@ -26,8 +26,9 @@ const selectValue = createSelector( `, }) export class WithNgRxStoreComponent { + private store = inject(Store); + value = this.store.pipe(select(selectValue)); - constructor(private store: Store) {} increment() { this.store.dispatch(increment()); diff --git a/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts b/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts index 915a88d..0bd5d86 100644 --- a/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts +++ b/apps/example-app/src/app/examples/07-with-ngrx-mock-store.ts @@ -1,5 +1,5 @@ import { AsyncPipe, NgForOf } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { createSelector, Store, select } from '@ngrx/store'; export const selectItems = createSelector( @@ -20,8 +20,9 @@ export const selectItems = createSelector( `, }) export class WithNgRxMockStoreComponent { + private store = inject(Store); + items = this.store.pipe(select(selectItems)); - constructor(private store: Store) {} send(item: string) { this.store.dispatch({ type: '[Item List] send', item }); diff --git a/apps/example-app/src/app/examples/08-directive.ts b/apps/example-app/src/app/examples/08-directive.ts index 63efe41..d6cd631 100644 --- a/apps/example-app/src/app/examples/08-directive.ts +++ b/apps/example-app/src/app/examples/08-directive.ts @@ -1,15 +1,15 @@ -import { Directive, HostListener, ElementRef, Input, OnInit } from '@angular/core'; +import { Directive, HostListener, ElementRef, Input, OnInit, inject } from '@angular/core'; @Directive({ standalone: true, selector: '[atlSpoiler]', }) export class SpoilerDirective implements OnInit { + private el = inject(ElementRef); + @Input() hidden = 'SPOILER'; @Input() visible = 'I am visible now...'; - constructor(private el: ElementRef) {} - ngOnInit() { this.el.nativeElement.textContent = this.hidden; } diff --git a/apps/example-app/src/app/examples/09-router.ts b/apps/example-app/src/app/examples/09-router.ts index e46773b..f29a4ef 100644 --- a/apps/example-app/src/app/examples/09-router.ts +++ b/apps/example-app/src/app/examples/09-router.ts @@ -1,5 +1,5 @@ import { AsyncPipe } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { ActivatedRoute, RouterLink, RouterOutlet } from '@angular/router'; import { map } from 'rxjs/operators'; @@ -32,10 +32,10 @@ export class RootComponent {} `, }) export class DetailComponent { + private route = inject(ActivatedRoute); id = this.route.paramMap.pipe(map((params) => params.get('id'))); text = this.route.queryParams.pipe(map((params) => params['text'])); subtext = this.route.queryParams.pipe(map((params) => params['subtext'])); - constructor(private route: ActivatedRoute) {} } @Component({ diff --git a/apps/example-app/src/app/examples/10-inject-token-dependency.ts b/apps/example-app/src/app/examples/10-inject-token-dependency.ts index f7b2f66..5cd6049 100644 --- a/apps/example-app/src/app/examples/10-inject-token-dependency.ts +++ b/apps/example-app/src/app/examples/10-inject-token-dependency.ts @@ -1,4 +1,4 @@ -import { Component, InjectionToken, Inject } from '@angular/core'; +import { Component, InjectionToken, inject } from '@angular/core'; export const DATA = new InjectionToken<{ text: string }>('Components Data'); @@ -8,5 +8,5 @@ export const DATA = new InjectionToken<{ text: string }>('Components Data'); template: ' {{ data.text }} ', }) export class DataInjectedComponent { - constructor(@Inject(DATA) public data: { text: string }) {} + protected data = inject(DATA); } diff --git a/apps/example-app/src/app/examples/12-service-component.ts b/apps/example-app/src/app/examples/12-service-component.ts index 1746eb2..f1b848b 100644 --- a/apps/example-app/src/app/examples/12-service-component.ts +++ b/apps/example-app/src/app/examples/12-service-component.ts @@ -1,5 +1,5 @@ import { AsyncPipe, NgForOf } from '@angular/common'; -import { Component, Injectable } from '@angular/core'; +import { Component, inject, Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; export class Customer { @@ -29,6 +29,6 @@ export class CustomersService { `, }) export class CustomersComponent { + private service = inject(CustomersService); customers$ = this.service.load(); - constructor(private service: CustomersService) {} } diff --git a/apps/example-app/src/app/examples/15-dialog.component.spec.ts b/apps/example-app/src/app/examples/15-dialog.component.spec.ts index df172be..51f8fb0 100644 --- a/apps/example-app/src/app/examples/15-dialog.component.spec.ts +++ b/apps/example-app/src/app/examples/15-dialog.component.spec.ts @@ -1,5 +1,5 @@ import { MatDialogRef } from '@angular/material/dialog'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { render, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; @@ -10,8 +10,8 @@ test('dialog closes', async () => { const closeFn = jest.fn(); await render(DialogContentComponent, { - imports: [NoopAnimationsModule], providers: [ + provideNoopAnimations(), { provide: MatDialogRef, useValue: { @@ -31,7 +31,7 @@ test('closes the dialog via the backdrop', async () => { const user = userEvent.setup(); await render(DialogComponent, { - imports: [NoopAnimationsModule], + providers: [provideNoopAnimations()], }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); @@ -55,7 +55,7 @@ test('opens and closes the dialog with buttons', async () => { const user = userEvent.setup(); await render(DialogComponent, { - imports: [NoopAnimationsModule], + providers: [provideNoopAnimations()], }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); diff --git a/apps/example-app/src/app/examples/15-dialog.component.ts b/apps/example-app/src/app/examples/15-dialog.component.ts index 029ee64..ce951f2 100644 --- a/apps/example-app/src/app/examples/15-dialog.component.ts +++ b/apps/example-app/src/app/examples/15-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @Component({ @@ -8,7 +8,7 @@ import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dial template: '', }) export class DialogComponent { - constructor(public dialog: MatDialog) {} + private dialog = inject(MatDialog); openDialog(): void { this.dialog.open(DialogContentComponent); @@ -29,7 +29,7 @@ export class DialogComponent { `, }) export class DialogContentComponent { - constructor(public dialogRef: MatDialogRef) {} + private dialogRef = inject>(MatDialogRef); cancel(): void { this.dialogRef.close(); diff --git a/apps/example-app/src/app/examples/16-input-getter-setter.ts b/apps/example-app/src/app/examples/16-input-getter-setter.ts index 4c18900..9d0654d 100644 --- a/apps/example-app/src/app/examples/16-input-getter-setter.ts +++ b/apps/example-app/src/app/examples/16-input-getter-setter.ts @@ -8,7 +8,6 @@ import { Component, Input } from '@angular/core'; {{ value }} `, }) -// eslint-disable-next-line @angular-eslint/component-class-suffix export class InputGetterSetter { @Input() set value(value: string) { this.originalValue = value; diff --git a/apps/example-app/src/app/examples/20-test-harness.ts b/apps/example-app/src/app/examples/20-test-harness.ts index 8e5e407..0ecb7b3 100644 --- a/apps/example-app/src/app/examples/20-test-harness.ts +++ b/apps/example-app/src/app/examples/20-test-harness.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @@ -11,7 +11,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; `, }) export class HarnessComponent { - constructor(private snackBar: MatSnackBar) {} + private snackBar = inject(MatSnackBar); openSnackBar() { return this.snackBar.open('Pizza Party!!!'); diff --git a/apps/example-app/src/app/examples/23-host-directive.ts b/apps/example-app/src/app/examples/23-host-directive.ts index 3e201c7..3d27f78 100644 --- a/apps/example-app/src/app/examples/23-host-directive.ts +++ b/apps/example-app/src/app/examples/23-host-directive.ts @@ -1,13 +1,12 @@ -import { Component, Directive, ElementRef, input, OnInit } from '@angular/core'; +import { Component, Directive, ElementRef, inject, input, OnInit } from '@angular/core'; @Directive({ selector: '[atlText]', }) export class TextDirective implements OnInit { + private el = inject(ElementRef); atlText = input(''); - constructor(private el: ElementRef) {} - ngOnInit() { this.el.nativeElement.textContent = this.atlText(); } diff --git a/eslint.config.mjs b/eslint.config.mjs index f46f58b..18ef575 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,14 +1,14 @@ // @ts-check -import eslint from "@eslint/js"; -import tseslint from "typescript-eslint"; -import angular from "angular-eslint"; +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import angular from 'angular-eslint'; import jestDom from 'eslint-plugin-jest-dom'; import testingLibrary from 'eslint-plugin-testing-library'; export default tseslint.config( { - files: ["**/*.ts"], + files: ['**/*.ts'], extends: [ eslint.configs.recommended, ...tseslint.configs.recommended, @@ -17,49 +17,42 @@ export default tseslint.config( ], processor: angular.processInlineTemplates, rules: { - "@angular-eslint/directive-selector": [ - "error", + '@angular-eslint/directive-selector': [ + 'error', { - type: "attribute", - prefix: "atl", - style: "camelCase", + type: 'attribute', + prefix: 'atl', + style: 'camelCase', }, ], - "@angular-eslint/component-selector": [ - "error", + '@angular-eslint/component-selector': [ + 'error', { - type: "element", - prefix: "atl", - style: "kebab-case", + type: 'element', + prefix: 'atl', + style: 'kebab-case', }, ], - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": [ - "error", + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, ], - // These are needed for test cases "@angular-eslint/prefer-standalone": "off", "@angular-eslint/no-input-rename": "off", }, }, { - files: ["**/*.spec.ts"], - extends: [ - jestDom.configs["flat/recommended"], - testingLibrary.configs["flat/angular"], - ], - }, + files: ['**/*.spec.ts'], + extends: [jestDom.configs['flat/recommended'], testingLibrary.configs['flat/angular']], + }, { - files: ["**/*.html"], - extends: [ - ...angular.configs.templateRecommended, - ...angular.configs.templateAccessibility, - ], + files: ['**/*.html'], + extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: {}, - } + }, ); diff --git a/package.json b/package.json index 3888395..341eb0f 100644 --- a/package.json +++ b/package.json @@ -27,53 +27,53 @@ "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "20.0.0", - "@angular/cdk": "20.0.0", - "@angular/common": "20.0.0", - "@angular/compiler": "20.0.0", - "@angular/core": "20.0.0", - "@angular/material": "20.0.0", - "@angular/platform-browser": "20.0.0", - "@angular/platform-browser-dynamic": "20.0.0", - "@angular/router": "20.0.0", - "@ngrx/store": "19.0.0", - "@nx/angular": "21.1.2", + "@angular/animations": "20.1.7", + "@angular/cdk": "20.1.6", + "@angular/common": "20.1.7", + "@angular/compiler": "20.1.7", + "@angular/core": "20.1.7", + "@angular/material": "20.1.6", + "@angular/platform-browser": "20.1.7", + "@angular/platform-browser-dynamic": "20.1.7", + "@angular/router": "20.1.7", + "@ngrx/store": "20.0.0", + "@nx/angular": "21.3.11", "@testing-library/dom": "^10.4.0", "rxjs": "7.8.0", "tslib": "~2.8.1", "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "20.0.0", - "@angular-devkit/core": "20.0.0", - "@angular-devkit/schematics": "20.0.0", - "@angular-eslint/builder": "19.2.0", - "@angular-eslint/eslint-plugin": "19.2.0", - "@angular-eslint/eslint-plugin-template": "19.2.0", - "@angular-eslint/schematics": "19.2.0", - "@angular-eslint/template-parser": "19.2.0", + "@angular-devkit/build-angular": "20.1.6", + "@angular-devkit/core": "20.1.6", + "@angular-devkit/schematics": "20.1.6", + "@angular-eslint/builder": "20.0.0", + "@angular-eslint/eslint-plugin": "20.0.0", + "@angular-eslint/eslint-plugin-template": "20.0.0", + "@angular-eslint/schematics": "20.0.0", + "@angular-eslint/template-parser": "20.0.0", "@angular/cli": "~20.0.0", - "@angular/compiler-cli": "20.0.0", - "@angular/forms": "20.0.0", - "@angular/language-service": "20.0.0", + "@angular/compiler-cli": "20.1.7", + "@angular/forms": "20.1.7", + "@angular/language-service": "20.1.7", "@eslint/eslintrc": "^2.1.1", - "@nx/eslint": "21.1.2", - "@nx/eslint-plugin": "21.1.2", - "@nx/jest": "21.1.2", - "@nx/node": "21.1.2", - "@nx/plugin": "21.1.2", - "@nx/workspace": "21.1.2", - "@schematics/angular": "20.0.0", + "@nx/eslint": "21.3.11", + "@nx/eslint-plugin": "21.3.11", + "@nx/jest": "21.3.11", + "@nx/node": "21.3.11", + "@nx/plugin": "21.3.11", + "@nx/workspace": "21.3.11", + "@schematics/angular": "20.1.6", "@testing-library/jasmine-dom": "^1.3.3", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.5.2", "@types/jasmine": "4.3.1", - "@types/jest": "29.5.14", + "@types/jest": "30.0.0", "@types/node": "22.10.1", "@types/testing-library__jasmine-dom": "^1.3.4", "@typescript-eslint/types": "^8.19.0", "@typescript-eslint/utils": "^8.19.0", - "angular-eslint": "19.2.0", + "angular-eslint": "20.0.0", "autoprefixer": "^10.4.20", "cpy-cli": "^5.0.0", "eslint": "^9.8.0", @@ -81,9 +81,9 @@ "eslint-plugin-testing-library": "~7.1.1", "jasmine-core": "4.2.0", "jasmine-spec-reporter": "7.0.0", - "jest": "29.7.0", - "jest-environment-jsdom": "29.7.0", - "jest-preset-angular": "14.4.2", + "jest": "30.0.5", + "jest-environment-jsdom": "30.0.5", + "jest-preset-angular": "15.0.0", "karma": "6.4.0", "karma-chrome-launcher": "^3.2.0", "karma-coverage": "^2.2.1", @@ -91,8 +91,8 @@ "karma-jasmine-html-reporter": "2.0.0", "lint-staged": "^15.3.0", "ng-mocks": "^14.13.1", - "ng-packagr": "20.0.0", - "nx": "21.1.2", + "ng-packagr": "20.1.0", + "nx": "21.3.11", "postcss": "^8.4.49", "postcss-import": "14.1.0", "postcss-preset-env": "7.5.0", @@ -100,9 +100,10 @@ "prettier": "2.6.2", "rimraf": "^5.0.10", "semantic-release": "^24.2.1", - "ts-jest": "29.1.0", + "ts-jest": "29.4.1", "ts-node": "10.9.1", "typescript": "5.8.2", - "typescript-eslint": "^8.19.0" + "typescript-eslint": "^8.19.0", + "jest-util": "30.0.5" } } diff --git a/projects/testing-library/eslint.config.mjs b/projects/testing-library/eslint.config.mjs index 8f627db..bd9b42b 100644 --- a/projects/testing-library/eslint.config.mjs +++ b/projects/testing-library/eslint.config.mjs @@ -1,8 +1,6 @@ // @ts-check -import tseslint from "typescript-eslint"; -import rootConfig from "../../eslint.config.mjs"; +import tseslint from 'typescript-eslint'; +import rootConfig from '../../eslint.config.mjs'; -export default tseslint.config( - ...rootConfig, -); +export default tseslint.config(...rootConfig); diff --git a/projects/testing-library/jest-utils/tests/create-mock.spec.ts b/projects/testing-library/jest-utils/tests/create-mock.spec.ts index 2393fe3..c20109b 100644 --- a/projects/testing-library/jest-utils/tests/create-mock.spec.ts +++ b/projects/testing-library/jest-utils/tests/create-mock.spec.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { fireEvent, render, screen } from '@testing-library/angular'; @@ -21,7 +21,7 @@ class FixtureService { template: ` `, }) class FixtureComponent { - constructor(private service: FixtureService) {} + private service = inject(FixtureService); print() { this.service.print(); diff --git a/projects/testing-library/tests/config.spec.ts b/projects/testing-library/tests/config.spec.ts index 041d991..7783961 100644 --- a/projects/testing-library/tests/config.spec.ts +++ b/projects/testing-library/tests/config.spec.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { render, configure, Config } from '../src/public_api'; import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; @@ -16,11 +16,10 @@ import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; standalone: false, }) class FormsComponent { + private formBuilder = inject(FormBuilder); form = this.formBuilder.group({ name: [''], }); - - constructor(private formBuilder: FormBuilder) {} } let originalConfig: Config; diff --git a/projects/testing-library/tests/integration.spec.ts b/projects/testing-library/tests/integration.spec.ts index 02ca290..70d0169 100644 --- a/projects/testing-library/tests/integration.spec.ts +++ b/projects/testing-library/tests/integration.spec.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Injectable, Input, Output } from '@angular/core'; +import { Component, EventEmitter, inject, Injectable, Input, Output } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { of, BehaviorSubject } from 'rxjs'; import { debounceTime, switchMap, map, startWith } from 'rxjs/operators'; @@ -54,6 +54,8 @@ class TableComponent { imports: [TableComponent, AsyncPipe], }) class EntitiesComponent { + private entitiesService = inject(EntitiesService); + private modalService = inject(ModalService); query = new BehaviorSubject(''); readonly entities = this.query.pipe( debounceTime(DEBOUNCE_TIME), @@ -63,8 +65,6 @@ class EntitiesComponent { startWith(entities), ); - constructor(private entitiesService: EntitiesService, private modalService: ModalService) {} - newEntityClicked() { this.modalService.open('new entity'); } diff --git a/projects/testing-library/tests/issues/issue-280.spec.ts b/projects/testing-library/tests/issues/issue-280.spec.ts index 711cbec..ea230e7 100644 --- a/projects/testing-library/tests/issues/issue-280.spec.ts +++ b/projects/testing-library/tests/issues/issue-280.spec.ts @@ -1,5 +1,5 @@ import { Location } from '@angular/common'; -import { Component, NgModule } from '@angular/core'; +import { Component, inject, NgModule } from '@angular/core'; import { RouterLink, RouterModule, RouterOutlet, Routes } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import userEvent from '@testing-library/user-event'; @@ -24,7 +24,7 @@ class FirstComponent {} `, }) class SecondComponent { - constructor(private location: Location) {} + private location = inject(Location); goBack() { this.location.back(); } diff --git a/projects/testing-library/tests/issues/issue-318.spec.ts b/projects/testing-library/tests/issues/issue-318.spec.ts index 3f1430e..1cfe5b8 100644 --- a/projects/testing-library/tests/issues/issue-318.spec.ts +++ b/projects/testing-library/tests/issues/issue-318.spec.ts @@ -1,21 +1,20 @@ -import {Component, OnDestroy, OnInit} from '@angular/core'; -import {Router} from '@angular/router'; -import {RouterTestingModule} from '@angular/router/testing'; -import {Subject, takeUntil} from 'rxjs'; -import {render} from "@testing-library/angular"; +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Subject, takeUntil } from 'rxjs'; +import { render } from '@testing-library/angular'; @Component({ selector: 'atl-app-fixture', template: '', }) class FixtureComponent implements OnInit, OnDestroy { + private readonly router = inject(Router); unsubscribe$ = new Subject(); - constructor(private router: Router) {} - ngOnInit(): void { this.router.events.pipe(takeUntil(this.unsubscribe$)).subscribe((evt) => { - this.eventReceived(evt) + this.eventReceived(evt); }); } @@ -29,15 +28,13 @@ class FixtureComponent implements OnInit, OnDestroy { } } - test('it does not invoke router events on init', async () => { const eventReceived = jest.fn(); await render(FixtureComponent, { imports: [RouterTestingModule], componentProperties: { - eventReceived - } + eventReceived, + }, }); expect(eventReceived).not.toHaveBeenCalled(); }); - diff --git a/projects/testing-library/tests/issues/issue-397-directive-overrides-component-input.spec.ts b/projects/testing-library/tests/issues/issue-397-directive-overrides-component-input.spec.ts index c2a02a8..c34e130 100644 --- a/projects/testing-library/tests/issues/issue-397-directive-overrides-component-input.spec.ts +++ b/projects/testing-library/tests/issues/issue-397-directive-overrides-component-input.spec.ts @@ -1,4 +1,4 @@ -import { Component, Directive, Input, OnInit } from '@angular/core'; +import { Component, Directive, inject, Input, OnInit } from '@angular/core'; import { render, screen } from '../../src/public_api'; test('the value set in the directive constructor is overriden by the input binding', async () => { @@ -48,7 +48,8 @@ class FixtureComponent { standalone: true, }) class InputOverrideViaConstructorDirective { - constructor(private fixture: FixtureComponent) { + private readonly fixture = inject(FixtureComponent); + constructor() { this.fixture.input = 'set by directive constructor'; } } @@ -59,7 +60,7 @@ class InputOverrideViaConstructorDirective { standalone: true, }) class InputOverrideViaOnInitDirective implements OnInit { - constructor(private fixture: FixtureComponent) {} + private readonly fixture = inject(FixtureComponent); ngOnInit(): void { this.fixture.input = 'set by directive ngOnInit'; diff --git a/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts b/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts index c4fa7a3..6dd5bc0 100644 --- a/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts +++ b/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef } from '@angular/core'; +import { Component, ElementRef, inject } from '@angular/core'; import { NgIf } from '@angular/common'; import { render } from '../../src/public_api'; @@ -9,8 +9,8 @@ test('declaration specific dependencies should be available for components', asy template: `
    Test
    `, }) class TestComponent { - // eslint-disable-next-line @typescript-eslint/no-empty-function - constructor(_elementRef: ElementRef) {} + // @ts-expect-error - testing purpose + private _el = inject(ElementRef); } await expect(async () => await render(TestComponent)).not.toThrow(); diff --git a/projects/testing-library/tests/issues/issue-435.spec.ts b/projects/testing-library/tests/issues/issue-435.spec.ts index e1e420f..2982319 100644 --- a/projects/testing-library/tests/issues/issue-435.spec.ts +++ b/projects/testing-library/tests/issues/issue-435.spec.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; -import { Component, Inject, Injectable } from '@angular/core'; +import { Component, inject, Injectable } from '@angular/core'; import { screen, render } from '../../src/public_api'; // Service @@ -23,7 +23,7 @@ class DemoService { `, }) class DemoComponent { - constructor(@Inject(DemoService) public demoService: DemoService) {} + protected readonly demoService = inject(DemoService); } test('issue #435', async () => { diff --git a/projects/testing-library/tests/issues/issue-493.spec.ts b/projects/testing-library/tests/issues/issue-493.spec.ts index 5d0e123..00a39b3 100644 --- a/projects/testing-library/tests/issues/issue-493.spec.ts +++ b/projects/testing-library/tests/issues/issue-493.spec.ts @@ -1,6 +1,6 @@ import { HttpClient, provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { Component, input } from '@angular/core'; +import { Component, inject, input } from '@angular/core'; import { render, screen } from '../../src/public_api'; test('succeeds', async () => { @@ -21,7 +21,7 @@ test('succeeds', async () => { template: '

    {{ value() }}

    ', }) class DummyComponent { + // @ts-expect-error - testing purpose + private _http = inject(HttpClient); value = input.required(); - // @ts-expect-error http is unused but needed for the test - constructor(private http: HttpClient) {} } diff --git a/projects/testing-library/tests/providers/component-provider.spec.ts b/projects/testing-library/tests/providers/component-provider.spec.ts index 9290d5b..b774064 100644 --- a/projects/testing-library/tests/providers/component-provider.spec.ts +++ b/projects/testing-library/tests/providers/component-provider.spec.ts @@ -1,4 +1,4 @@ -import { Injectable, Provider } from '@angular/core'; +import { inject, Injectable, Provider } from '@angular/core'; import { Component } from '@angular/core'; import { render, screen } from '../../src/public_api'; @@ -73,5 +73,5 @@ class Service { providers: [Service], }) class FixtureComponent { - constructor(public service: Service) {} + protected readonly service = inject(Service); } diff --git a/projects/testing-library/tests/providers/module-provider.spec.ts b/projects/testing-library/tests/providers/module-provider.spec.ts index bd39b81..8071029 100644 --- a/projects/testing-library/tests/providers/module-provider.spec.ts +++ b/projects/testing-library/tests/providers/module-provider.spec.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Component } from '@angular/core'; import { render, screen } from '../../src/public_api'; @@ -64,5 +64,5 @@ class Service { template: '{{service.foo()}}', }) class FixtureComponent { - constructor(public service: Service) {} + protected readonly service = inject(Service); } diff --git a/projects/testing-library/tests/render-template.spec.ts b/projects/testing-library/tests/render-template.spec.ts index e185f70..cddc28a 100644 --- a/projects/testing-library/tests/render-template.spec.ts +++ b/projects/testing-library/tests/render-template.spec.ts @@ -1,4 +1,4 @@ -import { Directive, HostListener, ElementRef, Input, Output, EventEmitter, Component } from '@angular/core'; +import { Directive, HostListener, ElementRef, Input, Output, EventEmitter, Component, inject } from '@angular/core'; import { render, fireEvent, screen } from '../src/public_api'; @@ -7,11 +7,12 @@ import { render, fireEvent, screen } from '../src/public_api'; selector: '[onOff]', }) class OnOffDirective { + private el = inject(ElementRef); @Input() on = 'on'; @Input() off = 'off'; @Output() clicked = new EventEmitter(); - constructor(private el: ElementRef) { + constructor() { this.el.nativeElement.textContent = 'init'; } @@ -26,12 +27,11 @@ class OnOffDirective { selector: '[update]', }) class UpdateInputDirective { + private readonly el = inject(ElementRef); @Input() set update(value: any) { this.el.nativeElement.textContent = value; } - - constructor(private el: ElementRef) {} } @Component({ diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index a93da90..243a5e8 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -116,7 +116,7 @@ describe('childComponentOverrides', () => { providers: [MySimpleService], }) class NestedChildFixtureComponent { - public constructor(public simpleService: MySimpleService) {} + protected simpleService = inject(MySimpleService); } @Component({ @@ -490,7 +490,7 @@ describe('initialRoute', () => { imports: [NgIf, AsyncPipe], }) class QueryParamFixtureComponent { - constructor(public route: ActivatedRoute) {} + private readonly route = inject(ActivatedRoute); paramPresent$ = this.route.queryParams.pipe(map((queryParams) => (queryParams?.param ? 'present' : 'missing'))); } From f6d107d2c9c0ff536cfc642620ce76d219f02b00 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:36:23 +0200 Subject: [PATCH 42/42] feat: add support for Angular bindings API (#547) Closes #546 --- .../24-bindings-api.component.spec.ts | 147 ++++++++++++++++++ .../app/examples/24-bindings-api.component.ts | 36 +++++ projects/testing-library/src/lib/models.ts | 23 +++ .../src/lib/testing-library.ts | 47 +++++- .../testing-library/tests/bindings.spec.ts | 141 +++++++++++++++++ 5 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 apps/example-app/src/app/examples/24-bindings-api.component.spec.ts create mode 100644 apps/example-app/src/app/examples/24-bindings-api.component.ts create mode 100644 projects/testing-library/tests/bindings.spec.ts diff --git a/apps/example-app/src/app/examples/24-bindings-api.component.spec.ts b/apps/example-app/src/app/examples/24-bindings-api.component.spec.ts new file mode 100644 index 0000000..6c0a0e3 --- /dev/null +++ b/apps/example-app/src/app/examples/24-bindings-api.component.spec.ts @@ -0,0 +1,147 @@ +import { signal, inputBinding, outputBinding, twoWayBinding } from '@angular/core'; +import { render, screen } from '@testing-library/angular'; +import { BindingsApiExampleComponent } from './24-bindings-api.component'; + +test('displays computed greeting message with input values', async () => { + await render(BindingsApiExampleComponent, { + bindings: [ + inputBinding('greeting', () => 'Hello'), + inputBinding('age', () => 25), + twoWayBinding('name', signal('John')), + ], + }); + + expect(screen.getByTestId('input-value')).toHaveTextContent('Hello John of 25 years old'); + expect(screen.getByTestId('computed-value')).toHaveTextContent('Hello John of 25 years old'); + expect(screen.getByTestId('current-age')).toHaveTextContent('Current age: 25'); +}); + +test('emits submitValue output when submit button is clicked', async () => { + const submitHandler = jest.fn(); + const nameSignal = signal('Alice'); + + await render(BindingsApiExampleComponent, { + bindings: [ + inputBinding('greeting', () => 'Good morning'), + inputBinding('age', () => 28), + twoWayBinding('name', nameSignal), + outputBinding('submitValue', submitHandler), + ], + }); + + const submitButton = screen.getByTestId('submit-button'); + submitButton.click(); + expect(submitHandler).toHaveBeenCalledWith('Alice'); +}); + +test('emits ageChanged output when increment button is clicked', async () => { + const ageChangedHandler = jest.fn(); + + await render(BindingsApiExampleComponent, { + bindings: [ + inputBinding('greeting', () => 'Hi'), + inputBinding('age', () => 20), + twoWayBinding('name', signal('Charlie')), + outputBinding('ageChanged', ageChangedHandler), + ], + }); + + const incrementButton = screen.getByTestId('increment-button'); + incrementButton.click(); + + expect(ageChangedHandler).toHaveBeenCalledWith(21); +}); + +test('updates name through two-way binding when input changes', async () => { + const nameSignal = signal('Initial Name'); + + await render(BindingsApiExampleComponent, { + bindings: [ + inputBinding('greeting', () => 'Hello'), + inputBinding('age', () => 25), + twoWayBinding('name', nameSignal), + ], + }); + + const nameInput = screen.getByTestId('name-input') as HTMLInputElement; + + // Verify initial value + expect(nameInput.value).toBe('Initial Name'); + expect(screen.getByTestId('input-value')).toHaveTextContent('Hello Initial Name of 25 years old'); + + // Update the signal externally + nameSignal.set('Updated Name'); + + // Verify the input and display update + expect(await screen.findByDisplayValue('Updated Name')).toBeInTheDocument(); + expect(screen.getByTestId('input-value')).toHaveTextContent('Hello Updated Name of 25 years old'); + expect(screen.getByTestId('computed-value')).toHaveTextContent('Hello Updated Name of 25 years old'); +}); + +test('updates computed value when inputs change', async () => { + const greetingSignal = signal('Good day'); + const nameSignal = signal('David'); + const ageSignal = signal(35); + + const { fixture } = await render(BindingsApiExampleComponent, { + bindings: [ + inputBinding('greeting', greetingSignal), + inputBinding('age', ageSignal), + twoWayBinding('name', nameSignal), + ], + }); + + // Initial state + expect(screen.getByTestId('computed-value')).toHaveTextContent('Good day David of 35 years old'); + + // Update greeting + greetingSignal.set('Good evening'); + fixture.detectChanges(); + expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening David of 35 years old'); + + // Update age + ageSignal.set(36); + fixture.detectChanges(); + expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening David of 36 years old'); + + // Update name + nameSignal.set('Daniel'); + fixture.detectChanges(); + expect(screen.getByTestId('computed-value')).toHaveTextContent('Good evening Daniel of 36 years old'); +}); + +test('handles multiple output emissions correctly', async () => { + const submitHandler = jest.fn(); + const ageChangedHandler = jest.fn(); + const nameSignal = signal('Emma'); + + await render(BindingsApiExampleComponent, { + bindings: [ + inputBinding('greeting', () => 'Hey'), + inputBinding('age', () => 22), + twoWayBinding('name', nameSignal), + outputBinding('submitValue', submitHandler), + outputBinding('ageChanged', ageChangedHandler), + ], + }); + + // Click submit button multiple times + const submitButton = screen.getByTestId('submit-button'); + submitButton.click(); + submitButton.click(); + + expect(submitHandler).toHaveBeenCalledTimes(2); + expect(submitHandler).toHaveBeenNthCalledWith(1, 'Emma'); + expect(submitHandler).toHaveBeenNthCalledWith(2, 'Emma'); + + // Click increment button multiple times + const incrementButton = screen.getByTestId('increment-button'); + incrementButton.click(); + incrementButton.click(); + incrementButton.click(); + + expect(ageChangedHandler).toHaveBeenCalledTimes(3); + expect(ageChangedHandler).toHaveBeenNthCalledWith(1, 23); + expect(ageChangedHandler).toHaveBeenNthCalledWith(2, 23); // Still 23 because age input doesn't change + expect(ageChangedHandler).toHaveBeenNthCalledWith(3, 23); +}); diff --git a/apps/example-app/src/app/examples/24-bindings-api.component.ts b/apps/example-app/src/app/examples/24-bindings-api.component.ts new file mode 100644 index 0000000..eb61ebe --- /dev/null +++ b/apps/example-app/src/app/examples/24-bindings-api.component.ts @@ -0,0 +1,36 @@ +import { Component, computed, input, model, numberAttribute, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'atl-bindings-api-example', + template: ` +
    {{ greetings() }} {{ name() }} of {{ age() }} years old
    +
    {{ greetingMessage() }}
    + + + +
    Current age: {{ age() }}
    + `, + standalone: true, + imports: [FormsModule], +}) +export class BindingsApiExampleComponent { + greetings = input('', { + alias: 'greeting', + }); + age = input.required({ transform: numberAttribute }); + name = model.required(); + submitValue = output(); + ageChanged = output(); + + greetingMessage = computed(() => `${this.greetings()} ${this.name()} of ${this.age()} years old`); + + submitName() { + this.submitValue.emit(this.name()); + } + + incrementAge() { + const newAge = this.age() + 1; + this.ageChanged.emit(newAge); + } +} diff --git a/projects/testing-library/src/lib/models.ts b/projects/testing-library/src/lib/models.ts index 318bd2b..b8628ba 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -7,6 +7,7 @@ import { Provider, Signal, InputSignalWithTransform, + Binding, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; @@ -307,6 +308,28 @@ export interface RenderComponentOptions; + /** + * @description + * An array of bindings to apply to the component using Angular's native bindings API. + * This provides a more direct way to bind inputs and outputs compared to the `inputs` and `on` options. + * + * @default + * [] + * + * @example + * import { inputBinding, outputBinding, twoWayBinding } from '@angular/core'; + * import { signal } from '@angular/core'; + * + * await render(AppComponent, { + * bindings: [ + * inputBinding('value', () => 'test value'), + * outputBinding('click', (event) => console.log(event)), + * twoWayBinding('name', signal('initial value')) + * ] + * }) + */ + bindings?: Binding[]; + /** * @description * A collection of providers to inject dependencies of the component. diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 4667727..a8bc1ea 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -11,6 +11,7 @@ import { SimpleChanges, Type, isStandalone, + Binding, } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing'; import { NavigationExtras, Router } from '@angular/router'; @@ -69,6 +70,7 @@ export async function render( componentOutputs = {}, inputs: newInputs = {}, on = {}, + bindings = [], componentProviders = [], childComponentOverrides = [], componentImports, @@ -192,11 +194,37 @@ export async function render( outputs: Partial, subscribeTo: OutputRefKeysWithCallback, ): Promise> => { - const createdFixture: ComponentFixture = await createComponent(componentContainer); + const createdFixture: ComponentFixture = await createComponent(componentContainer, bindings); + + // Always apply componentProperties (non-input properties) setComponentProperties(createdFixture, properties); - setComponentInputs(createdFixture, inputs); - setComponentOutputs(createdFixture, outputs); - subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo); + + // Angular doesn't allow mixing setInput with bindings + // So we use bindings OR traditional approach, but not both for inputs + if (bindings && bindings.length > 0) { + // When bindings are used, warn if traditional inputs/outputs are also specified + if (Object.keys(inputs).length > 0) { + console.warn( + '[@testing-library/angular]: You specified both bindings and traditional inputs. ' + + 'Only bindings will be used for inputs. Use bindings for all inputs to avoid this warning.', + ); + } + if (Object.keys(subscribeTo).length > 0) { + console.warn( + '[@testing-library/angular]: You specified both bindings and traditional output listeners. ' + + 'Consider using outputBinding() for all outputs for consistency.', + ); + } + + // Only apply traditional outputs, as bindings handle inputs + setComponentOutputs(createdFixture, outputs); + subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo); + } else { + // Use traditional approach when no bindings + setComponentInputs(createdFixture, inputs); + setComponentOutputs(createdFixture, outputs); + subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo); + } if (removeAngularAttributes) { createdFixture.nativeElement.removeAttribute('ng-version'); @@ -335,9 +363,18 @@ export async function render( }; } -async function createComponent(component: Type): Promise> { +async function createComponent( + component: Type, + bindings?: Binding[], +): Promise> { /* Make sure angular application is initialized before creating component */ await TestBed.inject(ApplicationInitStatus).donePromise; + + // Use the new bindings API if available and bindings are provided + if (bindings && bindings.length > 0) { + return TestBed.createComponent(component, { bindings }); + } + return TestBed.createComponent(component); } diff --git a/projects/testing-library/tests/bindings.spec.ts b/projects/testing-library/tests/bindings.spec.ts new file mode 100644 index 0000000..50718f9 --- /dev/null +++ b/projects/testing-library/tests/bindings.spec.ts @@ -0,0 +1,141 @@ +import { Component, input, output, inputBinding, outputBinding, twoWayBinding, signal, model } from '@angular/core'; +import { render, screen, aliasedInput } from '../src/public_api'; + +describe('Bindings API Support', () => { + @Component({ + selector: 'atl-bindings-test', + template: ` +
    {{ value() }}
    +
    {{ greeting() }}
    + + `, + standalone: true, + }) + class BindingsTestComponent { + value = input('default'); + greeting = input('hello', { alias: 'greet' }); + clicked = output(); + } + + @Component({ + selector: 'atl-two-way-test', + template: ` +
    {{ name() }}
    + + + `, + standalone: true, + }) + class TwoWayBindingTestComponent { + name = model('default'); + + updateName() { + this.name.set('updated from component'); + } + } + + test('supports inputBinding for regular inputs', async () => { + await render(BindingsTestComponent, { + bindings: [inputBinding('value', () => 'test-value'), inputBinding('greet', () => 'hi there')], + }); + + expect(screen.getByTestId('value')).toHaveTextContent('test-value'); + expect(screen.getByTestId('greeting')).toHaveTextContent('hi there'); + }); + + test('supports outputBinding for outputs', async () => { + const clickHandler = jest.fn(); + + await render(BindingsTestComponent, { + bindings: [inputBinding('value', () => 'bound-value'), outputBinding('clicked', clickHandler)], + }); + + const button = screen.getByTestId('emit-button'); + button.click(); + + expect(clickHandler).toHaveBeenCalledWith('clicked: bound-value'); + }); + + test('supports inputBinding with writable signal for re-rendering scenario', async () => { + const valueSignal = signal('initial-value'); + + await render(BindingsTestComponent, { + bindings: [inputBinding('value', valueSignal), inputBinding('greet', () => 'hi there')], + }); + + expect(screen.getByTestId('value')).toHaveTextContent('initial-value'); + expect(screen.getByTestId('greeting')).toHaveTextContent('hi there'); + + // Update the signal and verify it reflects in the component + valueSignal.set('updated-value'); + + // The binding should automatically update the component + expect(await screen.findByText('updated-value')).toBeInTheDocument(); + }); + + test('supports twoWayBinding for model signals', async () => { + const nameSignal = signal('initial name'); + + await render(TwoWayBindingTestComponent, { + bindings: [twoWayBinding('name', nameSignal)], + }); + + // Verify initial value + expect(screen.getByTestId('name-display')).toHaveTextContent('initial name'); + expect(screen.getByTestId('name-input')).toHaveValue('initial name'); + + // Update from outside (signal change) + nameSignal.set('updated from signal'); + expect(await screen.findByDisplayValue('updated from signal')).toBeInTheDocument(); + expect(screen.getByTestId('name-display')).toHaveTextContent('updated from signal'); + + // Update from component - let's trigger change detection after the click + const updateButton = screen.getByTestId('update-button'); + updateButton.click(); + + // Give Angular a chance to process the update and check both the signal and display + // The twoWayBinding should update the external signal + expect(await screen.findByText('updated from component')).toBeInTheDocument(); + expect(nameSignal()).toBe('updated from component'); + }); + + test('warns when mixing bindings with traditional inputs but still works', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const clickHandler = jest.fn(); + const bindingClickHandler = jest.fn(); + + await render(BindingsTestComponent, { + bindings: [inputBinding('value', () => 'binding-value'), outputBinding('clicked', bindingClickHandler)], + inputs: { + ...aliasedInput('greet', 'traditional-greeting'), // This will be ignored due to bindings + }, + on: { + clicked: clickHandler, // This should still work alongside bindings + }, + }); + + // Only binding should work for inputs + expect(screen.getByTestId('value')).toHaveTextContent('binding-value'); + expect(screen.getByTestId('greeting')).toHaveTextContent('hello'); // Default value, not traditional + + const button = screen.getByTestId('emit-button'); + button.click(); + + // Both binding and traditional handlers are called for outputs + expect(bindingClickHandler).toHaveBeenCalledWith('clicked: binding-value'); + expect(clickHandler).toHaveBeenCalledWith('clicked: binding-value'); + + // Shows warning about mixed usage for inputs + expect(consoleSpy).toHaveBeenCalledWith( + '[@testing-library/angular]: You specified both bindings and traditional inputs. ' + + 'Only bindings will be used for inputs. Use bindings for all inputs to avoid this warning.', + ); + + expect(consoleSpy).toHaveBeenCalledWith( + '[@testing-library/angular]: You specified both bindings and traditional output listeners. ' + + 'Consider using outputBinding() for all outputs for consistency.', + ); + + consoleSpy.mockRestore(); + }); +});