From b05105fe1a9fb483db3570d959eaeb7b5d2f8c49 Mon Sep 17 00:00:00 2001 From: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Date: Mon, 5 Jan 2026 12:24:34 +0100 Subject: [PATCH] feat: zoneless ATL --- .../schematics/collection.json | 5 + .../migrate-to-zoneless/index.spec.ts | 130 +++++++++ .../schematics/migrate-to-zoneless/index.ts | 73 +++++ .../migrate-to-zoneless/schema.json | 9 + .../dtl-as-dev-dependency/index.spec.ts | 1 + .../src/tests/zoneless.spec.ts | 177 ++++++++++++ projects/testing-library/zoneless/index.ts | 1 + .../testing-library/zoneless/ng-package.json | 1 + .../zoneless/src/public_api.ts | 260 ++++++++++++++++++ 9 files changed, 657 insertions(+) create mode 100644 projects/testing-library/schematics/migrate-to-zoneless/index.spec.ts create mode 100644 projects/testing-library/schematics/migrate-to-zoneless/index.ts create mode 100644 projects/testing-library/schematics/migrate-to-zoneless/schema.json create mode 100644 projects/testing-library/src/tests/zoneless.spec.ts create mode 100644 projects/testing-library/zoneless/index.ts create mode 100644 projects/testing-library/zoneless/ng-package.json create mode 100644 projects/testing-library/zoneless/src/public_api.ts diff --git a/projects/testing-library/schematics/collection.json b/projects/testing-library/schematics/collection.json index 0a435eb0..b8e6f33a 100644 --- a/projects/testing-library/schematics/collection.json +++ b/projects/testing-library/schematics/collection.json @@ -5,6 +5,11 @@ "factory": "./ng-add", "schema": "./ng-add/schema.json", "description": "Add @testing-library/angular to your application" + }, + "migrate-to-zoneless": { + "factory": "./migrate-to-zoneless", + "schema": "./migrate-to-zoneless/schema.json", + "description": "Migrate imports from @testing-library/angular to @testing-library/angular/zoneless" } } } diff --git a/projects/testing-library/schematics/migrate-to-zoneless/index.spec.ts b/projects/testing-library/schematics/migrate-to-zoneless/index.spec.ts new file mode 100644 index 00000000..6a3fcba4 --- /dev/null +++ b/projects/testing-library/schematics/migrate-to-zoneless/index.spec.ts @@ -0,0 +1,130 @@ +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { EmptyTree } from '@angular-devkit/schematics'; +import { test, expect } from 'vitest'; + +test('migrates imports from @testing-library/angular to @testing-library/angular/zoneless', async () => { + const before = ` +import { render, screen } from '@testing-library/angular'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + it('should render', async () => { + await render(AppComponent); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); +}); +`; + + const after = ` +import { render, screen } from '@testing-library/angular/zoneless'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + it('should render', async () => { + await render(AppComponent); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); +}); +`; + + const tree = await setup({ + 'src/app.spec.ts': before, + }); + + expect(tree.readContent('src/app.spec.ts')).toBe(after); +}); + +test('migrates imports with double quotes', async () => { + const before = `import { render } from "@testing-library/angular";`; + const after = `import { render } from "@testing-library/angular/zoneless";`; + + const tree = await setup({ + 'src/test.spec.ts': before, + }); + + expect(tree.readContent('src/test.spec.ts')).toBe(after); +}); + +test('migrates multiple imports in the same file', async () => { + const before = ` +import { render, screen } from '@testing-library/angular'; +import { fireEvent } from '@testing-library/angular'; +`; + + const after = ` +import { render, screen } from '@testing-library/angular/zoneless'; +import { fireEvent } from '@testing-library/angular/zoneless'; +`; + + const tree = await setup({ + 'src/multi.spec.ts': before, + }); + + expect(tree.readContent('src/multi.spec.ts')).toBe(after); +}); + +test('does not modify imports from other packages', async () => { + const before = ` +import { render } from '@testing-library/angular'; +import { screen } from '@testing-library/dom'; +import { Component } from '@angular/core'; +`; + + const after = ` +import { render } from '@testing-library/angular/zoneless'; +import { screen } from '@testing-library/dom'; +import { Component } from '@angular/core'; +`; + + const tree = await setup({ + 'src/other.spec.ts': before, + }); + + expect(tree.readContent('src/other.spec.ts')).toBe(after); +}); + +test('handles files without @testing-library/angular imports', async () => { + const content = ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: '
= BoundFunctions; + +export interface RenderResultextends RenderResultQueries { + /** + * @description + * The containing DOM node of your rendered Angular Component. + * This is a regular DOM node, so you can call container.querySelector etc. to inspect the children. + */ + container: HTMLElement; + + /** + * @description + * Prints out the component's DOM with syntax highlighting. + * Accepts an optional parameter, to print out a specific DOM node. + * + * @param + * element: The to be printed HTML element, if not provided it will log the whole component's DOM + */ + debug: ( + element?: Element | Document | (Element | Document)[], + maxLength?: number, + options?: PrettyDOMOptions, + ) => void; + + /** + * @description + * The Angular `ComponentFixture` of the component or the wrapper. + * If a template is provided, it will be the fixture of the wrapper. + * + * For more info see https://angular.io/api/core/testing/ComponentFixture + */ + fixture: ComponentFixture ; +} + +export interface RenderOptions { + /** + * @description + * Queries to bind. Overrides the default set from DOM Testing Library unless merged. + * + * @default + * undefined + * + * @example + * import * as customQueries from 'custom-queries' + * import { queries } from '@testing-library/angular' + * + * await render(AppComponent, { + * queries: { ...queries, ...customQueries } + * }) + */ + queries?: Q; + + /** + * @description + * Callback to configure the testbed before the compilation. + * + * @default + * () => {} + * + * @example + * await render(AppComponent, { + * configureTestBed: (testBed) => { } + * }) + */ + configureTestBed?: (testbed: TestBed) => void; + + /** + * @description + * Determines whether `fixture.detectChanges()` is called after the component is rendered. + * + * @default + * true + * + * @example + * await render(AppComponent, { + * detectChangesOnRender: false + * }) + */ + detectChangesOnRender?: boolean; + + /** + * @description + * An array of providers to be added to the testbed. + */ + providers?: (Provider | EnvironmentProviders)[]; +} + +export interface RenderComponentOptionsextends RenderOptions{ + /** + * @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[]; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface RenderTemplateOptions+ extends RenderOptions { + /** + * @description + * An Angular component to wrap the component in. + * The template will be overridden with the `template` option. + * NOTE: A standalone component cannot be used as a wrapper. + * + * @default + * `WrapperComponent`, a basic empty component. + * + * @example + * await render(``, { + * declarations: [SpoilerDirective] + * wrapper: CustomWrapperComponent + * }) + */ + wrapper?: Type; + + /** + * @description + * An object of properties to set on the wrapper component instance. + * This allows you to set input properties on the wrapper component, which can be useful for testing content projection or other wrapper-related functionality. + * + * @default + * undefined + * + * @example + * await render(` {{ message }}`, { + * wrapperProperties: { message: 'Hello World' } + * }) + */ + wrapperProperties?: Partial; + + /** + * @description + * An array of modules to be imported into the testbed. + */ + imports?: any[]; +} + +export async function render ( + component: Type , + renderOptions?: RenderComponentOptions, +): Promise >; +export async function render ( + template: string, + renderOptions?: RenderTemplateOptions , +): Promise >; +export async function render ( + componentOrTemplate: Type | string, + renderOptions: RenderComponentOptions | RenderTemplateOptions = {}, +): Promise > { + TestBed.configureTestingModule({ + declarations: [WrapperComponent], + imports: 'imports' in renderOptions ? renderOptions.imports : [], + providers: renderOptions.providers ?? [], + }); + + renderOptions.configureTestBed?.(TestBed); + await TestBed.compileComponents(); + + const fixture = + typeof componentOrTemplate === 'string' + ? await createWrapperFixture(componentOrTemplate, (renderOptions ?? {}) as RenderTemplateOptions ) + : await createComponentFixture(componentOrTemplate, (renderOptions ?? {}) as RenderComponentOptions); + + if (renderOptions.detectChangesOnRender !== false) { + fixture.detectChanges(); + } + + return { + fixture, + container: fixture.nativeElement, + debug: (element = fixture.nativeElement, maxLength?: number, options?: PrettyDOMOptions) => { + if (Array.isArray(element)) { + for (const e of element) { + console.log(prettyDOM(e, maxLength, options)); + } + } else { + console.log(prettyDOM(element, maxLength, options)); + } + }, + ...getQueriesForElement(fixture.nativeElement, renderOptions?.queries), + }; +} + +async function createComponentFixture ( + sut: Type , + options: RenderComponentOptions, +): Promise > { + return TestBed.createComponent(sut, { bindings: options.bindings || [] }); +} + +async function createWrapperFixture ( + sut: string, + options: RenderTemplateOptions , +): Promise > { + const wrapper = options.wrapper ?? (WrapperComponent as Type ); + TestBed.overrideTemplate(wrapper, sut); + const fixture = TestBed.createComponent(wrapper); + setWrapperProperties(fixture, options.wrapperProperties); + return fixture; +} + +function setWrapperProperties ( + fixture: ComponentFixture , + wrapperProperties: RenderTemplateOptions ['wrapperProperties'] = {}, +) { + for (const key of Object.keys(wrapperProperties)) { + const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key); + let _value = wrapperProperties[key]; + const defaultGetter = () => _value; + const extendedSetter = (value: any) => { + _value = value; + descriptor?.set?.call(fixture.componentInstance, _value); + fixture.changeDetectorRef.detectChanges(); + }; + + Object.defineProperty(fixture.componentInstance, key, { + get: descriptor?.get || defaultGetter, + set: extendedSetter, + // Allow the property to be defined again later. + // This happens when the component properties are updated after initial render. + // For Jest this is `true` by default, for Karma and a real browser the default is `false` + configurable: true, + }); + + descriptor?.set?.call(fixture.componentInstance, _value); + } + return fixture; +} + +@Component({ selector: 'atl-wrapper-component', template: '', standalone: false }) +class WrapperComponent {} + +export * from '@testing-library/dom';