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: '

Hello

' +}) +export class AppComponent {} +`; + + const tree = await setup({ + 'src/regular.ts': content, + }); + + expect(tree.readContent('src/regular.ts')).toBe(content); +}); + +test('migrates multiple files', async () => { + const tree = await setup({ + 'src/file1.spec.ts': `import { render } from '@testing-library/angular';`, + 'src/file2.spec.ts': `import { screen } from '@testing-library/angular';`, + 'src/file3.spec.ts': `import { fireEvent } from '@testing-library/angular';`, + }); + + expect(tree.readContent('src/file1.spec.ts')).toBe(`import { render } from '@testing-library/angular/zoneless';`); + expect(tree.readContent('src/file2.spec.ts')).toBe(`import { screen } from '@testing-library/angular/zoneless';`); + expect(tree.readContent('src/file3.spec.ts')).toBe(`import { fireEvent } from '@testing-library/angular/zoneless';`); +}); + +async function setup(files: Record) { + const collectionPath = path.join(__dirname, '../../../../dist/@testing-library/angular/schematics/collection.json'); + const schematicRunner = new SchematicTestRunner('schematics', collectionPath); + + const tree = new UnitTestTree(new EmptyTree()); + + for (const [filePath, content] of Object.entries(files)) { + tree.create(filePath, content); + } + + await schematicRunner.runSchematic('migrate-to-zoneless', {}, tree); + + return tree; +} diff --git a/projects/testing-library/schematics/migrate-to-zoneless/index.ts b/projects/testing-library/schematics/migrate-to-zoneless/index.ts new file mode 100644 index 00000000..c19cad32 --- /dev/null +++ b/projects/testing-library/schematics/migrate-to-zoneless/index.ts @@ -0,0 +1,73 @@ +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import * as ts from 'typescript'; + +export default function (): Rule { + return async (tree: Tree, context: SchematicContext) => { + context.logger.info('Migrating imports from @testing-library/angular to @testing-library/angular/zoneless...'); + + let filesUpdated = 0; + + tree.visit((path) => { + if (!path.endsWith('.ts') || path.includes('node_modules')) { + return; + } + + const content = tree.read(path); + if (!content) { + return; + } + + const text = content.toString('utf-8'); + + if (!text.includes('@testing-library/angular')) { + return; + } + + const sourceFile = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true); + + const changes: { start: number; end: number; newText: string }[] = []; + + function visit(node: ts.Node) { + if (ts.isImportDeclaration(node)) { + const moduleSpecifier = node.moduleSpecifier; + + if (ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === '@testing-library/angular') { + const fullText = moduleSpecifier.getFullText(sourceFile); + const quoteChar = fullText.trim()[0]; // ' or " + + changes.push({ + start: moduleSpecifier.getStart(sourceFile), + end: moduleSpecifier.getEnd(), + newText: `${quoteChar}@testing-library/angular/zoneless${quoteChar}`, + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + if (changes.length > 0) { + changes.sort((a, b) => b.start - a.start); + + let updatedText = text; + for (const change of changes) { + updatedText = updatedText.slice(0, change.start) + change.newText + updatedText.slice(change.end); + } + + tree.overwrite(path, updatedText); + filesUpdated++; + context.logger.info(`Updated: ${path}`); + } + }); + + if (filesUpdated > 0) { + context.logger.info(`✓ Successfully migrated ${filesUpdated} file(s) to use @testing-library/angular/zoneless`); + } else { + context.logger.warn('No files found with @testing-library/angular imports.'); + } + + return tree; + }; +} diff --git a/projects/testing-library/schematics/migrate-to-zoneless/schema.json b/projects/testing-library/schematics/migrate-to-zoneless/schema.json new file mode 100644 index 00000000..03250110 --- /dev/null +++ b/projects/testing-library/schematics/migrate-to-zoneless/schema.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "MigrateToZonelessSchema", + "title": "Migrate to Zoneless Schema", + "type": "object", + "description": "Migrate imports from @testing-library/angular to @testing-library/angular/zoneless", + "properties": {}, + "required": [] +} 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 98b7acc6..9b224142 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 @@ -1,6 +1,7 @@ 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('adds DTL to devDependencies', async () => { const tree = await setup({}); diff --git a/projects/testing-library/src/tests/zoneless.spec.ts b/projects/testing-library/src/tests/zoneless.spec.ts new file mode 100644 index 00000000..2c0fc6ec --- /dev/null +++ b/projects/testing-library/src/tests/zoneless.spec.ts @@ -0,0 +1,177 @@ +import { Component, inject, Injectable, model, output, outputBinding, signal, twoWayBinding } from '@angular/core'; +import { test, expect, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../zoneless'; + +@Injectable() +class CounterService { + private count = signal(0); + + getCount() { + return this.count(); + } + + increment() { + this.count.set(this.count() + 1); + } + + decrement() { + this.count.set(this.count() - 1); + } +} + +@Component({ + selector: 'atl-service-fixture', + template: ` + + {{ count }} + + `, +}) +class ServiceFixtureComponent { + private counterService = inject(CounterService); + count = this.counterService.getCount(); + + increment() { + this.counterService.increment(); + this.count = this.counterService.getCount(); + } + + decrement() { + this.counterService.decrement(); + this.count = this.counterService.getCount(); + } +} + +@Component({ + selector: 'atl-fixture', + template: ` + + {{ value() }} + + `, +}) +class FixtureComponent { + value = model(5); + valueUpdated = output(); + + decrement() { + this.value.set(this.value() - 1); + this.valueUpdated.emit(this.value()); + } + + increment() { + this.value.set(this.value() + 1); + this.valueUpdated.emit(this.value()); + } +} + +test('renders and interacts with the component', async () => { + const user = userEvent.setup(); + await render(FixtureComponent); + + const incrementControl = screen.getByRole('button', { name: '+' }); + const decrementControl = screen.getByRole('button', { name: '-' }); + const valueControl = screen.getByTestId('value'); + + expect(valueControl).toHaveTextContent('5'); + + await user.click(incrementControl); + await user.click(incrementControl); + + expect(valueControl).toHaveTextContent('7'); + + await user.click(decrementControl); + expect(valueControl).toHaveTextContent('6'); +}); + +test('renders and interacts with the component with detectChangesOnRender set to false', async () => { + const user = userEvent.setup(); + await render(FixtureComponent, { detectChangesOnRender: false }); + + const incrementControl = screen.getByRole('button', { name: '+' }); + const decrementControl = screen.getByRole('button', { name: '-' }); + const valueControl = screen.getByTestId('value'); + + // The initial value is not rendered until the first change detection runs, so we need to wait for it. + expect(valueControl).not.toHaveTextContent('5'); + await vi.waitFor(() => expect(valueControl).toHaveTextContent('5')); + + await user.click(incrementControl); + await user.click(incrementControl); + + expect(valueControl).toHaveTextContent('7'); + + await user.click(decrementControl); + expect(valueControl).toHaveTextContent('6'); +}); + +test('can set properties', async () => { + const user = userEvent.setup(); + const spy = vi.fn(); + await render(FixtureComponent, { + bindings: [twoWayBinding('value', signal(3)), outputBinding('valueUpdated', spy)], + }); + + const valueControl = screen.getByTestId('value'); + const incrementControl = screen.getByRole('button', { name: '+' }); + + expect(valueControl).toHaveTextContent('3'); + + await user.click(incrementControl); + expect(spy).toHaveBeenCalledWith(4); +}); + +test('renders and interacts with the component using a template', async () => { + const user = userEvent.setup(); + const wrapperValue = signal(10); + const wrapperOutput = vi.fn(); + await render(``, { + imports: [FixtureComponent], + wrapperProperties: { + wrapperValue: wrapperValue, + valueUpdated: wrapperOutput, + }, + }); + + expect(screen.getByTestId('value')).toHaveTextContent('10'); + + const incrementControl = screen.getByRole('button', { name: '+' }); + const decrementControl = screen.getByRole('button', { name: '-' }); + const valueControl = screen.getByTestId('value'); + + await user.click(incrementControl); + expect(wrapperOutput).toHaveBeenCalled(); + + await user.click(incrementControl); + expect(valueControl).toHaveTextContent('12'); + + await user.click(decrementControl); + expect(valueControl).toHaveTextContent('11'); + + wrapperValue.set(20); + // Wait for the component to update after changing the signal value + await vi.waitFor(() => expect(valueControl).toHaveTextContent('20')); +}); + +test('can provide custom service providers', async () => { + const user = userEvent.setup(); + await render(ServiceFixtureComponent, { + providers: [CounterService], + }); + + const incrementControl = screen.getByRole('button', { name: '+' }); + const decrementControl = screen.getByRole('button', { name: '-' }); + const counterControl = screen.getByTestId('counter'); + + expect(counterControl).toHaveTextContent('0'); + + await user.click(incrementControl); + expect(counterControl).toHaveTextContent('1'); + + await user.click(incrementControl); + expect(counterControl).toHaveTextContent('2'); + + await user.click(decrementControl); + expect(counterControl).toHaveTextContent('1'); +}); diff --git a/projects/testing-library/zoneless/index.ts b/projects/testing-library/zoneless/index.ts new file mode 100644 index 00000000..decc72d8 --- /dev/null +++ b/projects/testing-library/zoneless/index.ts @@ -0,0 +1 @@ +export * from './src/public_api'; diff --git a/projects/testing-library/zoneless/ng-package.json b/projects/testing-library/zoneless/ng-package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/projects/testing-library/zoneless/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/projects/testing-library/zoneless/src/public_api.ts b/projects/testing-library/zoneless/src/public_api.ts new file mode 100644 index 00000000..45f4e745 --- /dev/null +++ b/projects/testing-library/zoneless/src/public_api.ts @@ -0,0 +1,260 @@ +import { Component, type Type, type Binding, type Provider, type EnvironmentProviders } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + getQueriesForElement, + prettyDOM, + type BoundFunctions, + type PrettyDOMOptions, + type queries, + type Queries, +} from '@testing-library/dom'; + +export type RenderResultQueries = BoundFunctions; + +export interface RenderResult extends 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 RenderComponentOptions extends 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';