diff --git a/.all-contributorsrc b/.all-contributorsrc index 54c827ec..c60fd019 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -370,6 +370,102 @@ "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" + ] + }, + { + "login": "andreialecu", + "name": "Andrei Alecu", + "avatar_url": "https://avatars.githubusercontent.com/u/697707?v=4", + "profile": "https://lets.poker/", + "contributions": [ + "code", + "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" + ] + }, + { + "login": "mlz11", + "name": "Mahdi Lazraq", + "avatar_url": "https://avatars.githubusercontent.com/u/94069699?v=4", + "profile": "https://github.com/mlz11", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "Arthie", + "name": "Arthur Petrie", + "avatar_url": "https://avatars.githubusercontent.com/u/16376476?v=4", + "profile": "https://arthurpetrie.com", + "contributions": [ + "code" + ] + }, + { + "login": "FabienDehopre", + "name": "Fabien Dehopré", + "avatar_url": "https://avatars.githubusercontent.com/u/97023?v=4", + "profile": "https://github.com/FabienDehopre", + "contributions": [ + "code" + ] + }, + { + "login": "jvereecken", + "name": "Jamie Vereecken", + "avatar_url": "https://avatars.githubusercontent.com/u/108937550?v=4", + "profile": "https://github.com/jvereecken", + "contributions": [ + "code" + ] + }, + { + "login": "Christian24", + "name": "Christian24", + "avatar_url": "https://avatars.githubusercontent.com/u/2406635?v=4", + "profile": "https://github.com/Christian24", + "contributions": [ + "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" + ] + }, + { + "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/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index db976dcf..ac9d248f 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: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", diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 3c3629e6..00000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -node_modules diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 0a96094f..00000000 --- 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 5820814f..b35c5abb 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]' || '[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 }} @@ -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/.gitignore b/.gitignore index 215c8cba..22faaca8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ /.angular/cache .angular .nx +migrations.json .cache /.sass-cache /connect.lock @@ -43,3 +44,6 @@ yarn.lock # System Files .DS_Store Thumbs.db +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md +.history diff --git a/.node-version b/.node-version index 914d1a73..8fdd954d 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/.prettierignore b/.prettierignore index 2bdc4f98..03ff48d9 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/README.md b/README.md index ce2e906b..848aed06 100644 --- a/README.md +++ b/README.md @@ -98,22 +98,24 @@ counter.component.ts ```ts @Component({ - selector: 'app-counter', + selector: 'atl-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(); }); }); ``` @@ -168,14 +177,16 @@ You may also be interested in installing `jest-dom` so you can use ## Version compatibility -| Angular | Angular Testing Library | -| ------- | ---------------------------- | -| 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 @@ -256,6 +267,18 @@ 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

💻 ⚠️ + Andrei Alecu
Andrei Alecu

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

💻 + Mahdi Lazraq
Mahdi Lazraq

💻 ⚠️ + + + Arthur Petrie
Arthur Petrie

💻 + Fabien Dehopré
Fabien Dehopré

💻 + Jamie Vereecken
Jamie Vereecken

💻 + Christian24
Christian24

💻 👀 + Michal Štrajt
Michal Štrajt

💻 🐛 + J. Degand
J. Degand

💻 diff --git a/apps/example-app-karma/.eslintrc.json b/apps/example-app-karma/.eslintrc.json deleted file mode 100644 index 404aa664..00000000 --- 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 00000000..9e951e7a --- /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 00000000..bd9b42bf --- /dev/null +++ b/apps/example-app-karma/eslint.config.mjs @@ -0,0 +1,6 @@ +// @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 f8fa4a7f..54d79038 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/project.json b/apps/example-app-karma/project.json index 00820e35..27c4cbd4 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-karma/src/app/examples/login-form.spec.ts b/apps/example-app-karma/src/app/examples/login-form.spec.ts index 9c510652..d019e069 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'; @@ -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: ` @@ -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 new file mode 100644 index 00000000..9c967710 --- /dev/null +++ b/apps/example-app-karma/src/app/issues/issue-491.spec.ts @@ -0,0 +1,55 @@ +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'; + +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' })); + + expect(await screen.findByRole('heading', { name: 'Logged In' })).toBeVisible(); +}); + +@Component({ + template: ` +

Login

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

Logged In

`, +}) +class LoggedInComponent {} 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 9b044d1f..324e8a16 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 ed5e4d11..00000000 --- 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 00000000..9e951e7a --- /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 00000000..bd9b42bf --- /dev/null +++ b/apps/example-app/eslint.config.mjs @@ -0,0 +1,6 @@ +// @ts-check + +import tseslint from 'typescript-eslint'; +import rootConfig from '../../eslint.config.mjs'; + +export default tseslint.config(...rootConfig); diff --git a/apps/example-app/jest.config.ts b/apps/example-app/jest.config.ts index 4b0c248c..e0ea9c2d 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/project.json b/apps/example-app/project.json index ecbadfcb..1cf90ac4 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/apps/example-app/src/app/examples/00-single-component.ts b/apps/example-app/src/app/examples/00-single-component.ts index 7c132c2f..4a092390 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 645ce966..fd0d0c0e 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 c193d3e5..5a55bd57 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,63 @@ test('is possible to set input and listen for output', async () => { const sendValue = jest.fn(); await render(InputOutputComponent, { - componentInputs: { + inputs: { + 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, { + inputs: { value: 47, }, componentOutputs: { @@ -34,11 +90,11 @@ 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(); - 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 5bf70abb..3d7f9796 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 49756dca..c1e48c23 100644 --- a/apps/example-app/src/app/examples/03-forms.ts +++ b/apps/example-app/src/app/examples/03-forms.ts @@ -1,10 +1,10 @@ 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({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', imports: [ReactiveFormsModule, NgForOf, NgIf], template: `
@@ -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 ef80493e..2376c725 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'; @@ -18,7 +18,7 @@ import { MatNativeDateModule } from '@angular/material/core'; NgForOf, NgIf, ], - selector: 'app-fixture', + selector: 'atl-fixture', template: ` @@ -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 1f345b94..c6162e0b 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', @@ -21,7 +21,7 @@ export class CounterService { @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: ` {{ counter.value() }} @@ -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 b1db1d46..f478e528 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'); @@ -18,7 +18,7 @@ const selectValue = createSelector( @Component({ standalone: true, imports: [AsyncPipe], - selector: 'app-fixture', + selector: 'atl-fixture', template: ` {{ value | async }} @@ -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 7754bf1c..0bd5d864 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( @@ -10,16 +10,19 @@ export const selectItems = createSelector( @Component({ standalone: true, imports: [AsyncPipe, NgForOf], - selector: 'app-fixture', + selector: 'atl-fixture', template: `
    -
  • {{ item }}
  • +
  • + +
`, }) 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.spec.ts b/apps/example-app/src/app/examples/08-directive.spec.ts index 8b70b38c..28a41e98 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 40548b1e..d6cd631c 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: '[appSpoiler]', + 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 888d7fdf..f29a4efe 100644 --- a/apps/example-app/src/app/examples/09-router.ts +++ b/apps/example-app/src/app/examples/09-router.ts @@ -1,12 +1,12 @@ 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'; @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 }}

@@ -32,15 +32,15 @@ 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({ 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 6a17d538..5cd60498 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,12 +1,12 @@ -import { Component, InjectionToken, Inject } from '@angular/core'; +import { Component, InjectionToken, inject } from '@angular/core'; export const DATA = new InjectionToken<{ text: string }>('Components Data'); @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', 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/11-ng-content.spec.ts b/apps/example-app/src/app/examples/11-ng-content.spec.ts index 2f910252..468a3f29 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 d4446839..0dd668bc 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 2aed1657..f1b848ba 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 { @@ -19,7 +19,7 @@ export class CustomersService { @Component({ standalone: true, imports: [AsyncPipe, NgForOf], - selector: 'app-fixture', + selector: 'atl-fixture', template: `

  • @@ -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/13-scrolling.component.ts b/apps/example-app/src/app/examples/13-scrolling.component.ts index 7d7b2e7c..6a36ed8f 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 b54740a2..5cfd3e0e 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 f87732ad..64d7aaa2 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 97ad829a..51f8fb04 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 { provideNoopAnimations } from '@angular/platform-browser/animations'; import { render, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; @@ -10,6 +11,7 @@ test('dialog closes', async () => { const closeFn = jest.fn(); await render(DialogContentComponent, { providers: [ + provideNoopAnimations(), { provide: MatDialogRef, useValue: { @@ -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, { + providers: [provideNoopAnimations()], + }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); await user.click(openDialogButton); @@ -38,7 +42,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(); @@ -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, { + providers: [provideNoopAnimations()], + }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); await user.click(openDialogButton); 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 f6c89703..ce951f23 100644 --- a/apps/example-app/src/app/examples/15-dialog.component.ts +++ b/apps/example-app/src/app/examples/15-dialog.component.ts @@ -1,14 +1,14 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @Component({ standalone: true, imports: [MatDialogModule], - selector: 'app-dialog-overview-example', + selector: 'atl-dialog-overview-example', template: '', }) export class DialogComponent { - constructor(public dialog: MatDialog) {} + private dialog = inject(MatDialog); openDialog(): void { this.dialog.open(DialogContentComponent); @@ -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
    @@ -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 a9097a48..9d0654d3 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,13 +2,12 @@ import { Component, Input } from '@angular/core'; @Component({ standalone: true, - selector: 'app-fixture', + selector: 'atl-fixture', template: ` {{ derivedValue }} {{ 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/17-component-with-attribute-selector.spec.ts b/apps/example-app/src/app/examples/17-component-with-attribute-selector.spec.ts index ba69f70a..f33dee3e 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 ac2a25d3..930032c4 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 efcf0884..95eae3d5 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 6e4d9e3a..4a88a580 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 08d6afd7..0ecb7b35 100644 --- a/apps/example-app/src/app/examples/20-test-harness.ts +++ b/apps/example-app/src/app/examples/20-test-harness.ts @@ -1,9 +1,9 @@ -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'; @Component({ - selector: 'app-harness', + selector: 'atl-harness', standalone: true, imports: [MatButtonModule, MatSnackBarModule], template: ` @@ -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/21-deferable-view.component.ts b/apps/example-app/src/app/examples/21-deferable-view.component.ts index ce47a586..7b66d85a 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 113d3302..355e8ae4 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,48 +1,51 @@ -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', + 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 () => { await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + 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 () => { const { fixture } = await render(SignalInputComponent, { - componentInputs: { - greeting: 'Hello', + 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'); @@ -51,12 +54,13 @@ 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', + age: '45', }, - componentOutputs: { - submit: { emit: submitFn } as any, + on: { + submitValue: submitFn, }, }); @@ -67,9 +71,10 @@ 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', + age: '45', }, }); @@ -97,25 +102,27 @@ 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', + 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({ - componentInputs: { - greeting: 'bye', + 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 ddc0c905..27ed23b7 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', + selector: 'atl-signal-input', template: ` -
    {{ greetings() }} {{ name() }}
    +
    {{ greetings() }} {{ name() }} of {{ age() }} years old
    {{ greetingMessage() }}
    @@ -16,12 +16,13 @@ export class SignalInputComponent { greetings = input('', { alias: 'greeting', }); + age = input.required({ transform: numberAttribute }); name = model.required(); - submit = output(); + submitValue = output(); - greetingMessage = computed(() => `${this.greetings()} ${this.name()}`); + 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/app/examples/23-host-directive.spec.ts b/apps/example-app/src/app/examples/23-host-directive.spec.ts new file mode 100644 index 00000000..32892992 --- /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 00000000..3d27f788 --- /dev/null +++ b/apps/example-app/src/app/examples/23-host-directive.ts @@ -0,0 +1,20 @@ +import { Component, Directive, ElementRef, inject, input, OnInit } from '@angular/core'; + +@Directive({ + selector: '[atlText]', +}) +export class TextDirective implements OnInit { + private el = inject(ElementRef); + atlText = input(''); + + ngOnInit() { + this.el.nativeElement.textContent = this.atlText(); + } +} + +@Component({ + selector: 'atl-host-directive', + template: ``, + hostDirectives: [{ directive: TextDirective, inputs: ['atlText'] }], +}) +export class HostDirectiveComponent {} 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 00000000..6c0a0e32 --- /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 00000000..eb61ebeb --- /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/apps/example-app/src/test-setup.ts b/apps/example-app/src/test-setup.ts index 0da94a0a..96bfd347 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 00000000..9e951e7a --- /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 00000000..18ef575e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,58 @@ +// @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: '^_', + }, + ], + "@angular-eslint/prefer-standalone": "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/jest.config.ts b/jest.config.ts index 0830aab5..f5c10f47 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/nx.json b/nx.json index df534f76..a308e678 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 235d0eca..341eb0f5 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", @@ -27,83 +27,83 @@ "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", - "@testing-library/dom": "^10.0.0", + "@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.3.1", - "zone.js": "0.14.2" + "tslib": "~2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@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/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", - "@testing-library/jasmine-dom": "^1.2.0", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/user-event": "^14.4.3", + "@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.1.7", + "@angular/forms": "20.1.7", + "@angular/language-service": "20.1.7", + "@eslint/eslintrc": "^2.1.1", + "@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.1", - "@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", - "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/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": "20.0.0", + "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.5.0", - "jest-preset-angular": "14.1.0", + "jest": "30.0.5", + "jest-environment-jsdom": "30.0.5", + "jest-preset-angular": "15.0.0", "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", - "ng-mocks": "^14.11.0", - "ng-packagr": "18.0.0", - "nx": "19.1.0", - "postcss": "^8.4.5", + "lint-staged": "^15.3.0", + "ng-mocks": "^14.13.1", + "ng-packagr": "20.1.0", + "nx": "21.3.11", + "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", - "ts-jest": "29.1.0", + "rimraf": "^5.0.10", + "semantic-release": "^24.2.1", + "ts-jest": "29.4.1", "ts-node": "10.9.1", - "typescript": "5.4.5" + "typescript": "5.8.2", + "typescript-eslint": "^8.19.0", + "jest-util": "30.0.5" } } diff --git a/projects/testing-library/.eslintrc.json b/projects/testing-library/.eslintrc.json deleted file mode 100644 index 5a9d6905..00000000 --- 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 00000000..9e951e7a --- /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 00000000..bd9b42bf --- /dev/null +++ b/projects/testing-library/eslint.config.mjs @@ -0,0 +1,6 @@ +// @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-utils/tests/create-mock.spec.ts b/projects/testing-library/jest-utils/tests/create-mock.spec.ts index 2393fe30..c20109b6 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/jest.config.ts b/projects/testing-library/jest.config.ts index 189e52f8..bc5a665d 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/package.json b/projects/testing-library/package.json index 2852d027..6ea1a38c 100644 --- a/projects/testing-library/package.json +++ b/projects/testing-library/package.json @@ -29,10 +29,10 @@ "migrations": "./schematics/migrations/migrations.json" }, "peerDependencies": { - "@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/schematics/migrations/dtl-as-dev-dependency/index.spec.ts b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts index a3c0fd1e..ebc3922a 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/index.ts b/projects/testing-library/schematics/ng-add/index.ts index 24a0a3dc..868d2031 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() { + return (_tree: Tree, context: SchematicContext) => { + context.addTask(new NodePackageInstallTask()); + 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 3f35a9ad..30cc97d5 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 02bea61c..b0dcd227 100644 --- a/projects/testing-library/schematics/ng-add/schema.ts +++ b/projects/testing-library/schematics/ng-add/schema.ts @@ -1,2 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface Schema {} +export interface Schema { + installJestDom: boolean; + installUserEvent: boolean; +} diff --git a/projects/testing-library/src/lib/config.ts b/projects/testing-library/src/lib/config.ts index bd8ee9bb..075c91cf 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 fad33947..b8628bae 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,8 +1,34 @@ -import { Type, DebugElement } from '@angular/core'; -import {ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed} from '@angular/core/testing'; +import { + Type, + DebugElement, + ModuleWithProviders, + EventEmitter, + EnvironmentProviders, + Provider, + Signal, + InputSignalWithTransform, + Binding, +} 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 + : T[key] extends OutputRef + ? (val: U) => void + : never; +}; + export type RenderResultQueries = { [P in keyof Q]: BoundFunction }; export interface RenderResult extends RenderResultQueries { /** @@ -60,7 +86,7 @@ export interface RenderResult extend rerender: ( properties?: Pick< RenderTemplateOptions, - 'componentProperties' | 'componentInputs' | 'componentOutputs' | 'detectChangesOnRender' + 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => Promise; /** @@ -70,6 +96,31 @@ 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 InputSignalWithTransform + ? U + : 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 @@ -112,7 +163,7 @@ export interface RenderComponentOptions | unknown[])[]; /** * @description * A collection of providers needed to render the component via Dependency Injection, for example, injectable services or tokens. @@ -133,16 +184,15 @@ export interface RenderComponentOptions | ModuleWithProviders)[]; /** * @description * A collection of schemas needed to render the component. @@ -191,6 +241,7 @@ export interface RenderComponentOptions | { [alias: string]: unknown }; + componentInputs?: Partial | Record; + /** * @description - * An object to set `@Output` properties of the component + * An object to set `@Input` or `input()` properties of the component * * @default * {} * * @example - * const sendValue = (value) => { ... } + * await render(AppComponent, { + * inputs: { + * counterValue: 10, + * // explicitly define aliases using aliasedInput + * ...aliasedInput('someAlias', 'someValue') + * } + * }) + */ + inputs?: ComponentInput; + + /** + * @description + * An object to set `@Output` properties of the component + * @deprecated use the `on` option instead. When it is necessary to override properties, use the `componentProperties` option. + * @default + * {} + * + * @example + * const sendValue = new EventEmitter(); * await render(AppComponent, { * componentOutputs: { * send: { @@ -220,6 +290,46 @@ 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: (value) => sendValue(value) + * } + * }) + */ + on?: OutputRefKeysWithCallback; + + /** + * @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. @@ -236,7 +346,7 @@ export interface RenderComponentOptions | any[])[]; + componentImports?: (Type | unknown[])[]; /** * @description * Queries to bind. Overrides the default set from DOM Testing Library unless merged. @@ -379,21 +489,22 @@ export interface RenderComponentOptions { component: Type; - providers: any[]; + providers: Provider[]; } -// eslint-disable-next-line @typescript-eslint/ban-types +// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface RenderTemplateOptions extends RenderComponentOptions { /** * @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`, an empty component that strips the `ng-version` attribute @@ -417,5 +528,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 9b57b501..a8bc1ea3 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -2,15 +2,18 @@ import { ApplicationInitStatus, ChangeDetectorRef, Component, - isStandalone, NgZone, OnChanges, + OutputRef, + OutputRefSubscription, + Provider, SimpleChange, SimpleChanges, Type, + isStandalone, + Binding, } 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'; @@ -25,11 +28,19 @@ import { waitForOptions as dtlWaitForOptions, within as dtlWithin, } from '@testing-library/dom'; -import { ComponentOverride, RenderComponentOptions, RenderResult, RenderTemplateOptions } from './models'; import { getConfig } from './config'; +import { + ComponentOverride, + OutputRefKeysWithCallback, + RenderComponentOptions, + RenderResult, + RenderTemplateOptions, + Config, +} from './models'; + +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, @@ -57,9 +68,12 @@ export async function render( componentProperties = {}, componentInputs = {}, componentOutputs = {}, + inputs: newInputs = {}, + on = {}, + bindings = [], componentProviders = [], childComponentOverrides = [], - componentImports: componentImports, + componentImports, excludeComponentDeclaration = false, routes = [], removeAngularAttributes = false, @@ -70,7 +84,9 @@ export async function render( configureTestBed = () => { /* noop*/ }, - } = { ...globalConfig, ...renderOptions }; + } = { ...globalConfig, ...renderOptions } as RenderComponentOptions & + RenderTemplateOptions & + Config; dtlConfigure({ eventWrapper: (cb) => { @@ -93,7 +109,7 @@ export async function render( imports: imports.concat(defaultImports), routes, }), - providers: [...providers], + providers, schemas: [...schemas], deferBlockBehavior: deferBlockBehavior ?? DeferBlockBehavior.Manual, }); @@ -104,17 +120,15 @@ 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); - }); + // 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); + } 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('?'); @@ -146,7 +160,9 @@ export async function render( let result; if (zone) { - await zone.run(() => (result = doNavigate())); + await zone.run(() => { + result = doNavigate(); + }); } else { result = doNavigate(); } @@ -165,7 +181,85 @@ export async function render( let detectChanges: () => void; - const fixture = await renderFixture(componentProperties, componentInputs, componentOutputs); + const allInputs = { ...componentInputs, ...newInputs }; + + let renderedPropKeys = Object.keys(componentProperties); + let renderedInputKeys = Object.keys(allInputs); + 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, bindings); + + // Always apply componentProperties (non-input properties) + setComponentProperties(createdFixture, properties); + + // 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'); + const idAttribute = createdFixture.nativeElement.getAttribute('id'); + if (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, allInputs as any, componentOutputs, on); if (deferBlockStates) { if (Array.isArray(deferBlockStates)) { @@ -177,16 +271,13 @@ 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' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' > & { partialUpdate?: boolean }, ) => { - const newComponentInputs = properties?.componentInputs ?? {}; + const newComponentInputs = { ...properties?.componentInputs, ...properties?.inputs }; const changesInComponentInput = update( fixture, renderedInputKeys, @@ -205,6 +296,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, @@ -243,58 +350,31 @@ 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)), }; +} - 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(); - } +async function createComponent( + component: Type, + bindings?: Binding[], +): Promise> { + /* Make sure angular application is initialized before creating component */ + await TestBed.inject(ApplicationInitStatus).donePromise; - return createdFixture; + // Use the new bindings API if available and bindings are provided + if (bindings && bindings.length > 0) { + return TestBed.createComponent(component, { bindings }); } -} -async function createComponent(component: Type): Promise> { - /* Make sure angular application is initialized before creating component */ - await safeInject(ApplicationInitStatus).donePromise; return TestBed.createComponent(component); } @@ -355,6 +435,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)) { @@ -368,9 +469,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: providers as Provider[] } }); + } + } } function hasOnChangesHook(componentInstance: SutType): componentInstance is SutType & OnChanges { @@ -384,13 +487,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( @@ -406,10 +506,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]; + } } } @@ -432,27 +534,25 @@ function addAutoDeclarations( wrapper, }: Pick, 'declarations' | 'excludeComponentDeclaration' | 'wrapper'>, ) { + const nonStandaloneDeclarations = declarations.filter((d) => !isStandalone(d as Type)); 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( 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( @@ -490,7 +590,7 @@ async function waitForWrapper( let inFakeAsync = true; try { tick(0); - } catch (err) { + } catch { inFakeAsync = false; } @@ -561,7 +661,7 @@ if (typeof process === 'undefined' || !process.env?.ATL_SKIP_AUTO_CLEANUP) { } } -@Component({ selector: 'atl-wrapper-component', template: '' }) +@Component({ selector: 'atl-wrapper-component', template: '', standalone: false }) class WrapperComponent {} /** @@ -588,7 +688,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) { @@ -596,7 +696,7 @@ function detectChangesForMountedFixtures() { throw err; } } - }); + } } /** diff --git a/projects/testing-library/test-setup.ts b/projects/testing-library/test-setup.ts index 600d0857..be311bfe 100644 --- a/projects/testing-library/test-setup.ts +++ b/projects/testing-library/test-setup.ts @@ -1,6 +1,7 @@ -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'; -// eslint-disable-next-line @typescript-eslint/naming-convention +setupZoneTestEnv(); + Object.assign(global, { TextDecoder, TextEncoder }); diff --git a/projects/testing-library/tests/bindings.spec.ts b/projects/testing-library/tests/bindings.spec.ts new file mode 100644 index 00000000..50718f96 --- /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(); + }); +}); diff --git a/projects/testing-library/tests/config.spec.ts b/projects/testing-library/tests/config.spec.ts index bb8c61fc..7783961a 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'; @@ -13,13 +13,13 @@ 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/debug.spec.ts b/projects/testing-library/tests/debug.spec.ts index e1ad1dff..63ab7e67 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/defer-blocks.spec.ts b/projects/testing-library/tests/defer-blocks.spec.ts index 7405a4dd..ffd5e95b 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/find-by.spec.ts b/projects/testing-library/tests/find-by.spec.ts index 9d499fda..30f11ee3 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 eedec0e9..70d0169c 100644 --- a/projects/testing-library/tests/integration.spec.ts +++ b/projects/testing-library/tests/integration.spec.ts @@ -1,9 +1,10 @@ -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'; 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,8 +51,11 @@ class ModalService { `, + imports: [TableComponent, AsyncPipe], }) class EntitiesComponent { + private entitiesService = inject(EntitiesService); + private modalService = inject(ModalService); query = new BehaviorSubject(''); readonly entities = this.query.pipe( debounceTime(DEBOUNCE_TIME), @@ -42,8 +65,6 @@ class EntitiesComponent { startWith(entities), ); - constructor(private entitiesService: EntitiesService, private modalService: ModalService) {} - newEntityClicked() { this.modalService.open('new entity'); } @@ -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/integrations/ng-mocks.spec.ts b/projects/testing-library/tests/integrations/ng-mocks.spec.ts index 63584850..8886fb3f 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/issues/issue-230.spec.ts b/projects/testing-library/tests/issues/issue-230.spec.ts index fe004b62..8df58f66 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 19f644ef..ea230e78 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 { 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'; 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 {} @@ -22,7 +24,7 @@ class FirstComponent {} `, }) class SecondComponent { - constructor(private location: Location) {} + private location = inject(Location); goBack() { this.location.back(); } @@ -35,7 +37,6 @@ const routes: Routes = [ ]; @NgModule({ - declarations: [FirstComponent, SecondComponent], imports: [RouterModule.forRoot(routes)], exports: [RouterModule], }) @@ -47,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-318.spec.ts b/projects/testing-library/tests/issues/issue-318.spec.ts index 3f1430e8..1cfe5b85 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-389.spec.ts b/projects/testing-library/tests/issues/issue-389.spec.ts index 03f25f74..626d3889 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 2da43b32..7be9913e 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-397-directive-overrides-component-input.spec.ts b/projects/testing-library/tests/issues/issue-397-directive-overrides-component-input.spec.ts index c2a02a8c..c34e1304 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-398-component-without-host-id.spec.ts b/projects/testing-library/tests/issues/issue-398-component-without-host-id.spec.ts index 4508d642..c775a2ab 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 05e6e11a..6dd5bc0c 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,7 +9,8 @@ test('declaration specific dependencies should be available for components', asy template: `
    Test
    `, }) class TestComponent { - 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 e1e420f9..2982319b 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-437.spec.ts b/projects/testing-library/tests/issues/issue-437.spec.ts index 2d0e7c51..dbf2506b 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-492.spec.ts b/projects/testing-library/tests/issues/issue-492.spec.ts new file mode 100644 index 00000000..a1e44b09 --- /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 } 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', { name: 'username 1' })).toBeInTheDocument(); + + // emitting a second username + user.next({ name: 'username 2' }); + + // assert the second username is rendered + expect(await screen.findByRole('heading', { name: 'username 2' })).toBeInTheDocument(); +}); + +@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'); + } +} 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 00000000..00a39b37 --- /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, inject, 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 { + // @ts-expect-error - testing purpose + private _http = inject(HttpClient); + value = input.required(); +} diff --git a/projects/testing-library/tests/providers/component-provider.spec.ts b/projects/testing-library/tests/providers/component-provider.spec.ts index 3c3ec0cf..b774064e 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 { inject, 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() { @@ -55,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 bd39b81b..80710291 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 a6892dbc..cddc28a1 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({ @@ -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 56f4608f..243a5e81 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -10,12 +10,17 @@ import { Injectable, EventEmitter, Output, + ElementRef, + inject, + output, + input, + model, } from '@angular/core'; -import { NoopAnimationsModule, BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; import { TestBed } from '@angular/core/testing'; -import { render, fireEvent, screen } from '../src/public_api'; +import { render, fireEvent, screen, OutputRefKeysWithCallback, aliasedInput } 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({ @@ -31,7 +36,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 @@ -41,34 +46,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 }, }) class MockChildFixtureComponent {} @@ -77,18 +79,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(); @@ -112,17 +113,15 @@ describe('childComponentOverrides', () => { @Component({ selector: 'atl-child-fixture', template: `{{ simpleService.value }}`, - standalone: true, providers: [MySimpleService], }) class NestedChildFixtureComponent { - public constructor(public simpleService: MySimpleService) {} + protected simpleService = inject(MySimpleService); } @Component({ selector: 'atl-parent-fixture', template: ``, - standalone: true, imports: [NestedChildFixtureComponent], }) class ParentFixtureComponent {} @@ -183,35 +182,151 @@ describe('componentOutputs', () => { }); }); -describe('animationModule', () => { - @NgModule({ - declarations: [FixtureComponent], - }) - class FixtureModule {} - describe('excludeComponentDeclaration', () => { - it('does not throw if component is declared in an imported module', async () => { - await render(FixtureComponent, { - imports: [FixtureModule], - excludeComponentDeclaration: true, - }); +describe('on', () => { + @Component({ template: `` }) + class TestFixtureWithEventEmitterComponent { + @Output() readonly event = new EventEmitter(); + } + + @Component({ template: `` }) + class TestFixtureWithDerivedEventComponent { + @Output() readonly event = fromEvent(inject(ElementRef).nativeElement, 'click'); + } + + @Component({ template: `` }) + class TestFixtureWithFunctionalOutputComponent { + readonly event = output(); + } + + @Component({ template: `` }) + 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('adds NoopAnimationsModule by default', async () => { - await render(FixtureComponent); - const noopAnimationsModule = TestBed.inject(NoopAnimationsModule); - expect(noopAnimationsModule).toBeDefined(); + 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('does not add NoopAnimationsModule if BrowserAnimationsModule is an import', async () => { - await render(FixtureComponent, { - imports: [BrowserAnimationsModule], + 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 browserAnimationsModule = TestBed.inject(BrowserAnimationsModule); - expect(browserAnimationsModule).toBeDefined(); + const newSpy = jest.fn(); + await rerender({ on: { event: newSpy } }); - expect(() => TestBed.inject(NoopAnimationsModule)).toThrow(); + 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 wrong event type + _test({ event: fnWithNumberArg }); + _test({ event: fnWithVoidArg }); + + // @ts-expect-error wrong event type + _test({ event: fnWithNumberArg }); + _test({ event: fnWithMouseEventArg }); + + // @ts-expect-error wrong event type + _test({ event: fnWithNumberArg }); + _test({ event: fnWithStringArg }); + + // @ts-expect-error wrong event type + _test({ event: fnWithNumberArg }); + _test({ event: fnWithMouseEventArg }); + + // add a statement so the test succeeds + expect(true).toBeTruthy(); + }); +}); + +describe('excludeComponentDeclaration', () => { + @Component({ + selector: 'atl-fixture', + template: ` + + + `, + standalone: false, + }) + class NotStandaloneFixtureComponent {} + + @NgModule({ + declarations: [NotStandaloneFixtureComponent], + }) + class FixtureModule {} + + it('does not throw if component is declared in an imported module', async () => { + await render(NotStandaloneFixtureComponent, { + imports: [FixtureModule], + excludeComponentDeclaration: true, + }); }); }); @@ -256,11 +371,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); }); @@ -272,11 +387,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); }); @@ -328,14 +443,12 @@ describe('DebugElement', () => { describe('initialRoute', () => { @Component({ - standalone: true, selector: 'atl-fixture2', template: ``, }) class SecondaryFixtureComponent {} @Component({ - standalone: true, selector: 'atl-router-fixture', template: ``, imports: [RouterModule], @@ -372,13 +485,12 @@ 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], }) class QueryParamFixtureComponent { - constructor(public route: ActivatedRoute) {} + private readonly route = inject(ActivatedRoute); paramPresent$ = this.route.queryParams.pipe(map((queryParams) => (queryParams?.param ? 'present' : 'missing'))); } @@ -405,3 +517,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 571d6429..04b8185a 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(); 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 5c16a539..64d6c356 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;