diff --git a/.all-contributorsrc b/.all-contributorsrc index 83afbd44..c60fd019 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -294,6 +294,178 @@ "code", "test" ] + }, + { + "login": "dzonatan", + "name": "Rokas Brazdลพionis", + "avatar_url": "https://avatars.githubusercontent.com/u/5166666?v=4", + "profile": "https://github.com/dzonatan", + "contributions": [ + "code" + ] + }, + { + "login": "mateusduraes", + "name": "Mateus Duraes", + "avatar_url": "https://avatars.githubusercontent.com/u/19319404?v=4", + "profile": "https://github.com/mateusduraes", + "contributions": [ + "code" + ] + }, + { + "login": "JJosephttg", + "name": "Josh Joseph", + "avatar_url": "https://avatars.githubusercontent.com/u/23690250?v=4", + "profile": "https://github.com/JJosephttg", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "shaman-apprentice", + "name": "Torsten Knauf", + "avatar_url": "https://avatars.githubusercontent.com/u/3596742?v=4", + "profile": "https://github.com/shaman-apprentice", + "contributions": [ + "maintenance" + ] + }, + { + "login": "antischematic", + "name": "antischematic", + "avatar_url": "https://avatars.githubusercontent.com/u/12976684?v=4", + "profile": "https://github.com/antischematic", + "contributions": [ + "bug", + "ideas" + ] + }, + { + "login": "TrustNoOneElse", + "name": "Florian Pabst", + "avatar_url": "https://avatars.githubusercontent.com/u/25935352?v=4", + "profile": "https://github.com/TrustNoOneElse", + "contributions": [ + "code" + ] + }, + { + "login": "markgoho", + "name": "Mark Goho", + "avatar_url": "https://avatars.githubusercontent.com/u/9759954?v=4", + "profile": "https://rochesterparks.org", + "contributions": [ + "maintenance", + "doc" + ] + }, + { + "login": "jwbaart", + "name": "Jan-Willem Baart", + "avatar_url": "https://avatars.githubusercontent.com/u/10973990?v=4", + "profile": "http://jwbaart.dev", + "contributions": [ + "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, @@ -301,5 +473,7 @@ "projectOwner": "testing-library", "repoType": "github", "repoHost": "https://github.com", - "skipCi": true + "skipCi": true, + "commitConvention": "angular", + "commitType": "docs" } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..ac9d248f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,42 @@ +// For format details, see https://aka.ms/devcontainer.json. +{ + "name": "angular-testing-library", + "image": "mcr.microsoft.com/devcontainers/typescript-node:22-bullseye", + + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {} + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "npm install --force", + "onCreateCommand": "sudo cp .devcontainer/welcome-message.txt /usr/local/etc/vscode-dev-containers/first-run-notice.txt", + "waitFor": "postCreateCommand", + + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + "settings": { + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[md]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + } + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] + } + } +} diff --git a/.devcontainer/welcome-message.txt b/.devcontainer/welcome-message.txt new file mode 100644 index 00000000..952d2c48 --- /dev/null +++ b/.devcontainer/welcome-message.txt @@ -0,0 +1,7 @@ +๐Ÿ‘‹ Welcome to "Angular Testing Library" in GitHub Codespaces! + +๐Ÿ› ๏ธ Your environment is fully setup with all the required software. + +๐Ÿ” To explore VS Code to its fullest, search using the Command Palette (Cmd/Ctrl + Shift + P or F1). + +๐Ÿ“ Edit away, run your app as usual, and we'll automatically make it available for you to access. \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 40a33190..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "root": true, - "ignorePatterns": ["**/*"], - "plugins": ["@nrwl/nx", "testing-library"], - "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "rules": { - "@nrwl/nx/enforce-module-boundaries": [ - "error", - { - "enforceBuildableLibDependency": true, - "allow": [], - "depConstraints": [ - { - "sourceTag": "*", - "onlyDependOnLibsWithTags": ["*"] - } - ] - } - ] - } - }, - { - "files": ["*.ts", "*.tsx"], - "extends": ["plugin:@nrwl/nx/typescript"], - "rules": {} - }, - { - "files": ["*.js", "*.jsx"], - "extends": ["plugin:@nrwl/nx/javascript"], - "rules": {} - }, - { - "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/.githooks/pre-commit b/.githooks/pre-commit old mode 100644 new mode 100755 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69e0fda3..b35c5abb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: - 'main' - 'beta' pull_request: {} + workflow_dispatch: + +permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -13,24 +16,30 @@ concurrency: jobs: build_test_release: + permissions: + actions: write + contents: write + strategy: matrix: - node-version: ${{ fromJSON((github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') && '[16]' || '[14,16]') }} + 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 }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: use Node.js ${{ matrix.node-version }} on ${{ matrix.os }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: install - run: npm install + run: npm install --force - name: build run: npm run build -- --skip-nx-cache - name: test 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 b1a42cd6..22faaca8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ # misc /.angular/cache .angular +.nx +migrations.json .cache /.sass-cache /connect.lock @@ -42,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 b6a7d89c..8fdd954d 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -16 +22 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 9aa8a3df..03ff48d9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -53,3 +53,6 @@ deployment.yaml # System Files .DS_Store Thumbs.db + +/.nx/cache +/.nx/workspace-data \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c53210b..d3ecd5a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Hi there, thanks for being willing to contribute! ## Setup - Fork and clone the repository -- Install dependencies via via `npm install` +- Install dependencies via `npm install` - Create a new feature branch via `git checkout -b feature-branch-name` ## Testing diff --git a/README.md b/README.md index eb47f898..848aed06 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,12 @@

@testing-library/angular

- - hedgehog - +Octopus with the Angular logo

Simple and complete Angular testing utilities that encourage good testing practices.

@@ -28,7 +26,7 @@ practices.

[![version][version-badge]][package] [![downloads][downloads-badge]][npmtrends] [![MIT License][license-badge]][license] -[![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors) +[![All Contributors](https://img.shields.io/github/all-contributors/testing-library/angular-testing-library?color=ee8449&style=flat-square)](#contributors) [![PRs Welcome][prs-badge]][prs] [![Code of Conduct][coc-badge]][coc] [![Discord][discord-badge]][discord] @@ -47,23 +45,29 @@ practices.

+[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=137053739) + ## Table of Contents +- [Table of Contents](#table-of-contents) - [The problem](#the-problem) - [This solution](#this-solution) - [Example](#example) - [Installation](#installation) +- [Version compatibility](#version-compatibility) - [Guiding Principles](#guiding-principles) - [Contributors](#contributors) - [Docs](#docs) - [FAQ](#faq) + - [I am using Reactive Forms and the `jest-dom` matcher `toHaveFormValues` always returns an empty object or there are missing fields. Why?](#i-am-using-reactive-forms-and-the-jest-dom-matcher-tohaveformvalues-always-returns-an-empty-object-or-there-are-missing-fields-why) - [Issues](#issues) - [๐Ÿ› Bugs](#-bugs) - [๐Ÿ’ก Feature Requests](#-feature-requests) - [โ“ Questions](#-questions) +- [Getting started with GitHub Codespaces](#getting-started-with-github-codespaces) - [LICENSE](#license) @@ -94,22 +98,24 @@ counter.component.ts ```ts @Component({ - selector: '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); } } ``` @@ -117,23 +123,30 @@ export class CounterComponent { counter.component.spec.ts ```typescript -import { render, screen, fireEvent } from '@testing-library/angular'; -import { CounterComponent } from './counter.component.ts'; +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: /increment/i }); + const incrementButton = screen.getByRole('button', { name: '+' }); fireEvent.click(incrementButton); - expect(screen.getByText('Current Count: 6')); + expect(screen.getByText('Current Count: 6')).toBeVisible(); }); }); ``` @@ -143,10 +156,18 @@ describe('Counter', () => { ## Installation This module is distributed via [npm][npm] which is bundled with [node][node] and -should be installed as one of your project's `devDependencies`: +should be installed as one of your project's `devDependencies`. +Starting from ATL version 17, you also need to install `@testing-library/dom`: ```bash -npm install @testing-library/angular --save-dev +npm install --save-dev @testing-library/angular @testing-library/dom +``` + +Or, you can use the `ng add` command. +This sets up your project to use Angular Testing Library, which also includes the installation of `@testing-library/dom`. + +```bash +ng add @testing-library/angular ``` You may also be interested in installing `jest-dom` so you can use @@ -154,6 +175,19 @@ You may also be interested in installing `jest-dom` so you can use > [**Docs**](https://testing-library.com/angular) +## Version compatibility + +| 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 > [The more your tests resemble the way your software is used, the more @@ -183,46 +217,70 @@ Thanks goes to these people ([emoji key][emojis]): - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Tim Deschryver

๐Ÿ’ป ๐Ÿ“– ๐Ÿš‡ โš ๏ธ

Michaรซl De Boey

๐Ÿ“–

Ignacio Le Fluk

๐Ÿ’ป โš ๏ธ

Tamรกs Szabรณ

๐Ÿ’ป

Gregor Woiwode

๐Ÿ’ป

Toni Villena

๐Ÿ› ๐Ÿ’ป ๐Ÿ“– โš ๏ธ

ShPelles

๐Ÿ“–

Miluoshi

๐Ÿ’ป โš ๏ธ

Nick McCurdy

๐Ÿ“–

Srinivasan Sekar

๐Ÿ“–

Bitcollage

๐Ÿ“–

Emil Sundin

๐Ÿ’ป

Ombrax

๐Ÿ’ป

Rafael Santana

๐Ÿ’ป โš ๏ธ ๐Ÿ›

Benjamin Blackwood

๐Ÿ“– โš ๏ธ

Gustavo Porto

๐Ÿ“–

Bo Vandersteene

๐Ÿ’ป

Janek

๐Ÿ’ป โš ๏ธ

Gleb Irovich

๐Ÿ’ป โš ๏ธ

Arjen

๐Ÿ’ป ๐Ÿšง

Suguru Inatomi

๐Ÿ’ป ๐Ÿค”

Amit Miran

๐Ÿš‡

Jan-Willem Willebrands

๐Ÿ’ป

Sandro

๐Ÿ’ป ๐Ÿ›

Michael Westphal

๐Ÿ’ป โš ๏ธ

Lukas

๐Ÿ’ป

Matan Borenkraout

๐Ÿšง

mleimer

๐Ÿ“– โš ๏ธ

MeIr

๐Ÿ› โš ๏ธ

John Dengis

๐Ÿ’ป โš ๏ธ
Tim Deschryver
Tim Deschryver

๐Ÿ’ป ๐Ÿ“– ๐Ÿš‡ โš ๏ธ
Michaรซl De Boey
Michaรซl De Boey

๐Ÿ“–
Ignacio Le Fluk
Ignacio Le Fluk

๐Ÿ’ป โš ๏ธ
Tamรกs Szabรณ
Tamรกs Szabรณ

๐Ÿ’ป
Gregor Woiwode
Gregor Woiwode

๐Ÿ’ป
Toni Villena
Toni Villena

๐Ÿ› ๐Ÿ’ป ๐Ÿ“– โš ๏ธ
ShPelles
ShPelles

๐Ÿ“–
Miluoshi
Miluoshi

๐Ÿ’ป โš ๏ธ
Nick McCurdy
Nick McCurdy

๐Ÿ“–
Srinivasan Sekar
Srinivasan Sekar

๐Ÿ“–
Bitcollage
Bitcollage

๐Ÿ“–
Emil Sundin
Emil Sundin

๐Ÿ’ป
Ombrax
Ombrax

๐Ÿ’ป
Rafael Santana
Rafael Santana

๐Ÿ’ป โš ๏ธ ๐Ÿ›
Benjamin Blackwood
Benjamin Blackwood

๐Ÿ“– โš ๏ธ
Gustavo Porto
Gustavo Porto

๐Ÿ“–
Bo Vandersteene
Bo Vandersteene

๐Ÿ’ป
Janek
Janek

๐Ÿ’ป โš ๏ธ
Gleb Irovich
Gleb Irovich

๐Ÿ’ป โš ๏ธ
Arjen
Arjen

๐Ÿ’ป ๐Ÿšง
Suguru Inatomi
Suguru Inatomi

๐Ÿ’ป ๐Ÿค”
Amit Miran
Amit Miran

๐Ÿš‡
Jan-Willem Willebrands
Jan-Willem Willebrands

๐Ÿ’ป
Sandro
Sandro

๐Ÿ’ป ๐Ÿ›
Michael Westphal
Michael Westphal

๐Ÿ’ป โš ๏ธ
Lukas
Lukas

๐Ÿ’ป
Matan Borenkraout
Matan Borenkraout

๐Ÿšง
mleimer
mleimer

๐Ÿ“– โš ๏ธ
MeIr
MeIr

๐Ÿ› โš ๏ธ
John Dengis
John Dengis

๐Ÿ’ป โš ๏ธ
Rokas Brazdลพionis
Rokas Brazdลพionis

๐Ÿ’ป
Mateus Duraes
Mateus Duraes

๐Ÿ’ป
Josh Joseph
Josh Joseph

๐Ÿ’ป โš ๏ธ
Torsten Knauf
Torsten Knauf

๐Ÿšง
antischematic
antischematic

๐Ÿ› ๐Ÿค”
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

๐Ÿ’ป
@@ -240,7 +298,7 @@ Contributions of any kind welcome! ## FAQ -##### I am using Reactive Forms and the `jest-dom` matcher `toHaveFormValues` always returns an empty object or there are missing fields. Why? +### I am using Reactive Forms and the `jest-dom` matcher `toHaveFormValues` always returns an empty object or there are missing fields. Why? Only form elements with a `name` attribute will have their values passed to `toHaveFormsValues`. @@ -270,6 +328,16 @@ instead of filing an issue on GitHub. - [Discord][discord] - [Stack Overflow][stackoverflow] +## Getting started with GitHub Codespaces + +To get started, create a codespace for this repository by clicking this ๐Ÿ‘‡ + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=137053739) + +A codespace will open in a web-based version of Visual Studio Code. The [dev container](.devcontainer/devcontainer.json) is fully configured with software needed for this project. + +**Note**: Dev containers is an open spec which is supported by [GitHub Codespaces](https://github.com/codespaces) and [other tools](https://containers.dev/supporting). + ## LICENSE MIT diff --git a/angular.json b/angular.json deleted file mode 100644 index dfd6bcf1..00000000 --- a/angular.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": 2, - "projects": { - "example-app": "apps/example-app", - "example-app-karma": "apps/example-app-karma", - "jest-utils": "projects/jest-utils", - "testing-library": "projects/testing-library" - } -} diff --git a/apps/example-app-karma/.browserslistrc b/apps/example-app-karma/.browserslistrc deleted file mode 100644 index 427441dc..00000000 --- a/apps/example-app-karma/.browserslistrc +++ /dev/null @@ -1,17 +0,0 @@ -# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. -# For additional information regarding the format and rule options, please see: -# https://github.com/browserslist/browserslist#queries - -# For the full list of supported browsers by the Angular framework, please see: -# https://angular.io/guide/browser-support - -# You can see what browsers were selected by your queries by running: -# npx browserslist - -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/apps/example-app-karma/.eslintrc.json b/apps/example-app-karma/.eslintrc.json deleted file mode 100644 index f1a2cfb5..00000000 --- a/apps/example-app-karma/.eslintrc.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "extends": "../../.eslintrc.json", - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts"], - "extends": ["plugin:@nrwl/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:@nrwl/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 94b4a463..27c4cbd4 100644 --- a/apps/example-app-karma/project.json +++ b/apps/example-app-karma/project.json @@ -1,8 +1,10 @@ { + "name": "example-app-karma", + "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "root": "apps/example-app-karma", "sourceRoot": "apps/example-app-karma/src", "prefix": "app", + "tags": [], "generators": {}, "targets": { "build": { @@ -12,7 +14,6 @@ "outputPath": "dist/apps/example-app-karma", "index": "apps/example-app-karma/src/index.html", "main": "apps/example-app-karma/src/main.ts", - "polyfills": "apps/example-app-karma/src/polyfills.ts", "tsConfig": "apps/example-app-karma/tsconfig.app.json", "assets": ["apps/example-app-karma/src/favicon.ico", "apps/example-app-karma/src/assets"], "styles": [], @@ -26,12 +27,6 @@ "maximumWarning": "6kb" } ], - "fileReplacements": [ - { - "replace": "apps/example-app-karma/src/environments/environment.ts", - "with": "apps/example-app-karma/src/environments/environment.prod.ts" - } - ], "outputHashing": "all" }, "development": { @@ -49,34 +44,25 @@ "executor": "@angular-devkit/build-angular:dev-server", "configurations": { "production": { - "browserTarget": "example-app-karma:build:production" + "buildTarget": "example-app-karma:build:production" }, "development": { - "browserTarget": "example-app-karma:build:development" + "buildTarget": "example-app-karma:build:development" } }, - "defaultConfiguration": "development" + "defaultConfiguration": "development", + "continuous": true }, "lint": { - "executor": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": [ - "apps/example-app-karma/**/*.ts", - "apps/example-app-karma/**/*.html", - "apps/example-app-karma/src/**/*.html" - ] - }, - "outputs": ["{options.outputFile}"] + "executor": "@nx/eslint:lint" }, "test": { "executor": "@angular-devkit/build-angular:karma", "options": { "main": "apps/example-app-karma/src/test.ts", "tsConfig": "apps/example-app-karma/tsconfig.spec.json", - "polyfills": "apps/example-app-karma/src/polyfills.ts", "karmaConfig": "apps/example-app-karma/karma.conf.js" } } - }, - "tags": [] + } } diff --git a/apps/example-app-karma/src/app/app.module.ts b/apps/example-app-karma/src/app/app.module.ts deleted file mode 100644 index e636d9eb..00000000 --- a/apps/example-app-karma/src/app/app.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { NgModule } from '@angular/core'; - -@NgModule({ - declarations: [], - imports: [BrowserModule, BrowserAnimationsModule], -}) -export class AppModule {} 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 new file mode 100644 index 00000000..d019e069 --- /dev/null +++ b/apps/example-app-karma/src/app/examples/login-form.spec.ts @@ -0,0 +1,66 @@ +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'; +import { NgIf } from '@angular/common'; + +it('should create a component with inputs and a button to submit', async () => { + await render(LoginComponent); + + expect(screen.getByRole('textbox', { name: 'email' })).toBeInTheDocument(); + expect(screen.getByLabelText('password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'submit' })).toBeInTheDocument(); +}); + +it('should display invalid message and submit button must be disabled', async () => { + const user = userEvent.setup(); + + await render(LoginComponent); + + const email = screen.getByRole('textbox', { name: 'email' }); + const password = screen.getByLabelText('password'); + + await user.type(email, 'foo'); + await user.type(password, 's'); + + expect(screen.getAllByText(/is invalid/i).length).toBe(2); + expect(screen.getAllByRole('alert').length).toBe(2); + expect(screen.getByRole('button', { name: 'submit' })).toBeDisabled(); +}); + +@Component({ + selector: 'atl-login', + standalone: true, + imports: [ReactiveFormsModule, NgIf], + template: ` +

Login

+ +
+ +
Email is invalid
+ +
Password is invalid
+ +
+ `, +}) +class LoginComponent { + private fb = inject(FormBuilder); + + form: FormGroup = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + }); + + get email(): FormControl { + return this.form.get('email') as FormControl; + } + + get password(): FormControl { + return this.form.get('password') as FormControl; + } + + onSubmit(_fg: FormGroup): void { + // do nothing + } +} diff --git a/apps/example-app-karma/src/app/issues/issue-222.spec.ts b/apps/example-app-karma/src/app/issues/issue-222.spec.ts deleted file mode 100644 index b6ac5204..00000000 --- a/apps/example-app-karma/src/app/issues/issue-222.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from '@testing-library/angular'; - -it('https://github.com/testing-library/angular-testing-library/issues/222 with rerender', async () => { - const { rerender } = await render(`
Hello {{ name}}
`, { - componentProperties: { - name: 'Sarah', - }, - }); - - expect(screen.getByText('Hello Sarah')).toBeTruthy(); - - await rerender({ name: 'Mark' }); - - expect(screen.getByText('Hello Mark')).toBeTruthy(); -}); - -it('https://github.com/testing-library/angular-testing-library/issues/222 with change', async () => { - const { change } = await render(`
Hello {{ name}}
`, { - componentProperties: { - name: 'Sarah', - }, - }); - - expect(screen.getByText('Hello Sarah')).toBeTruthy(); - await change({ name: 'Mark' }); - - expect(screen.getByText('Hello Mark')).toBeTruthy(); -}); 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 new file mode 100644 index 00000000..324e8a16 --- /dev/null +++ b/apps/example-app-karma/src/app/issues/rerender.spec.ts @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/angular'; + +it('can rerender component', async () => { + const { rerender } = await render(`
Hello {{ name}}
`, { + componentProperties: { + name: 'Sarah', + }, + }); + + expect(screen.getByText('Hello Sarah')).toBeInTheDocument(); + + await rerender({ componentProperties: { name: 'Mark' } }); + + expect(screen.getByText('Hello Mark')).toBeInTheDocument(); +}); diff --git a/apps/example-app-karma/src/assets/.gitkeep b/apps/example-app-karma/src/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/example-app-karma/src/environments/environment.prod.ts b/apps/example-app-karma/src/environments/environment.prod.ts deleted file mode 100644 index c9669790..00000000 --- a/apps/example-app-karma/src/environments/environment.prod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true, -}; diff --git a/apps/example-app-karma/src/environments/environment.ts b/apps/example-app-karma/src/environments/environment.ts deleted file mode 100644 index 64c3e6c7..00000000 --- a/apps/example-app-karma/src/environments/environment.ts +++ /dev/null @@ -1,15 +0,0 @@ -// This file can be replaced during build by using the `fileReplacements` array. -// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. -// The list of file replacements can be found in `workspace.json`. - -export const environment = { - production: false, -}; - -/* - * In development mode, to ignore zone related error stack frames such as - * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can - * import the following file, but please comment it out in production mode - * because it will have performance impact when throw error - */ -// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/apps/example-app-karma/src/favicon.ico b/apps/example-app-karma/src/favicon.ico deleted file mode 100644 index 8081c7ce..00000000 Binary files a/apps/example-app-karma/src/favicon.ico and /dev/null differ diff --git a/apps/example-app-karma/src/index.html b/apps/example-app-karma/src/index.html deleted file mode 100644 index 930133fd..00000000 --- a/apps/example-app-karma/src/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - AngularTestingLibraryApp - - - - - - - - - diff --git a/apps/example-app-karma/src/main.ts b/apps/example-app-karma/src/main.ts deleted file mode 100644 index 741c9eb8..00000000 --- a/apps/example-app-karma/src/main.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; - -if (environment.production) { - enableProdMode(); -} - -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch((err) => console.log(err)); diff --git a/apps/example-app-karma/src/polyfills.ts b/apps/example-app-karma/src/polyfills.ts deleted file mode 100644 index f84fd8a6..00000000 --- a/apps/example-app-karma/src/polyfills.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * This file includes polyfills needed by Angular and is loaded before the app. - * You can add your own extra polyfills to this file. - * - * This file is divided into 2 sections: - * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. - * 2. Application imports. Files imported after ZoneJS that should be loaded before your main - * file. - * - * The current setup is for so-called "evergreen" browsers; the last versions of browsers that - * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), - * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. - * - * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html - */ - -/** ************************************************************************************************* - * BROWSER POLYFILLS - */ - -/** IE9, IE10 and IE11 requires all of the following polyfills. **/ -// import 'core-js/es6/symbol'; -// import 'core-js/es6/object'; -// import 'core-js/es6/function'; -// import 'core-js/es6/parse-int'; -// import 'core-js/es6/parse-float'; -// import 'core-js/es6/number'; -// import 'core-js/es6/math'; -// import 'core-js/es6/string'; -// import 'core-js/es6/date'; -// import 'core-js/es6/array'; -// import 'core-js/es6/regexp'; -// import 'core-js/es6/map'; -// import 'core-js/es6/weak-map'; -// import 'core-js/es6/set'; - -/** IE10 and IE11 requires the following for the Reflect API. */ -// import 'core-js/es6/reflect'; - -/** Evergreen browsers require these. **/ -// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. - -/** - * By default, zone.js will patch all possible macroTask and DomEvents - * user can disable parts of macroTask/DomEvents patch by setting following flags - */ - -// (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame -// (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick -// (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames - -/* - * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js - * with the following flag, it will bypass `zone.js` patch for IE/Edge - */ -// (window as any).__Zone_enable_cross_context_check = true; - -/** ************************************************************************************************* - * Zone JS is required by default for Angular itself. - */ -import 'zone.js'; // Included with Angular CLI. - -/** ************************************************************************************************* - * APPLICATION IMPORTS - */ diff --git a/apps/example-app-karma/src/test.ts b/apps/example-app-karma/src/test.ts index c2ab726d..e6bf956d 100644 --- a/apps/example-app-karma/src/test.ts +++ b/apps/example-app-karma/src/test.ts @@ -1,5 +1,5 @@ -// This file is required by karma.conf.js and loads recursively all the .spec and framework files -import 'zone.js/dist/zone-testing'; +import 'zone.js'; +import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; import JasmineDOM from '@testing-library/jasmine-dom'; @@ -9,11 +9,5 @@ beforeEach(() => { jasmine.addMatchers(JasmineDOM); }); -declare const require: any; - // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {}); -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().map(context); diff --git a/apps/example-app-karma/tsconfig.app.json b/apps/example-app-karma/tsconfig.app.json index 4de7101b..46150c25 100644 --- a/apps/example-app-karma/tsconfig.app.json +++ b/apps/example-app-karma/tsconfig.app.json @@ -3,9 +3,11 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "types": [], - "allowJs": true + "allowJs": true, + "target": "ES2022", + "useDefineForClassFields": false }, - "files": ["src/main.ts", "src/polyfills.ts"], + "files": ["src/main.ts"], "include": ["src/**/*.d.ts"], "exclude": ["**/*.test.ts", "**/*.spec.ts"] } diff --git a/apps/example-app-karma/tsconfig.json b/apps/example-app-karma/tsconfig.json index 0cfe7c56..9453a196 100644 --- a/apps/example-app-karma/tsconfig.json +++ b/apps/example-app-karma/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.base.json", "files": [], "include": [], - "compilerOptions": {}, + "compilerOptions": { + "target": "es2020" + }, "angularCompilerOptions": { "strictInjectionParameters": true, "strictInputAccessModifiers": true, diff --git a/apps/example-app-karma/tsconfig.spec.json b/apps/example-app-karma/tsconfig.spec.json index f4b0d715..0f4baec3 100644 --- a/apps/example-app-karma/tsconfig.spec.json +++ b/apps/example-app-karma/tsconfig.spec.json @@ -2,8 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": ["jasmine", "node", "@testing-library/jasmine-dom"] + "types": ["jasmine", "node", "@testing-library/jasmine-dom"], + "target": "ES2022", + "useDefineForClassFields": false }, - "files": ["src/test.ts", "src/polyfills.ts"], + "files": ["src/test.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"] } diff --git a/apps/example-app/.browserslistrc b/apps/example-app/.browserslistrc deleted file mode 100644 index 427441dc..00000000 --- a/apps/example-app/.browserslistrc +++ /dev/null @@ -1,17 +0,0 @@ -# This file is used by the build system to adjust CSS and JS output to support the specified browsers below. -# For additional information regarding the format and rule options, please see: -# https://github.com/browserslist/browserslist#queries - -# For the full list of supported browsers by the Angular framework, please see: -# https://angular.io/guide/browser-support - -# You can see what browsers were selected by your queries by running: -# npx browserslist - -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. diff --git a/apps/example-app/.eslintrc.json b/apps/example-app/.eslintrc.json deleted file mode 100644 index 897bee00..00000000 --- a/apps/example-app/.eslintrc.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "extends": "../../.eslintrc.json", - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts"], - "extends": ["plugin:@nrwl/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:@nrwl/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.js b/apps/example-app/jest.config.ts similarity index 67% rename from apps/example-app/jest.config.js rename to apps/example-app/jest.config.ts index 2be66c61..e0ea9c2d 100644 --- a/apps/example-app/jest.config.js +++ b/apps/example-app/jest.config.ts @@ -1,7 +1,6 @@ -module.exports = { - name: 'Example App', +export default { displayName: { - name: 'Example', + name: 'Example App', color: 'blue', }, preset: '../../jest.preset.js', diff --git a/apps/example-app/project.json b/apps/example-app/project.json index ebab97cb..1cf90ac4 100644 --- a/apps/example-app/project.json +++ b/apps/example-app/project.json @@ -1,8 +1,10 @@ { + "name": "example-app", + "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", - "root": "apps/example-app", "sourceRoot": "apps/example-app/src", "prefix": "app", + "tags": [], "generators": {}, "targets": { "build": { @@ -26,12 +28,6 @@ "maximumWarning": "6kb" } ], - "fileReplacements": [ - { - "replace": "apps/example-app/src/environments/environment.ts", - "with": "apps/example-app/src/environments/environment.prod.ts" - } - ], "outputHashing": "all" }, "development": { @@ -49,34 +45,31 @@ "executor": "@angular-devkit/build-angular:dev-server", "configurations": { "production": { - "browserTarget": "example-app:build:production" + "buildTarget": "example-app:build:production" }, "development": { - "browserTarget": "example-app:build:development" + "buildTarget": "example-app:build:development" } }, - "defaultConfiguration": "development" + "defaultConfiguration": "development", + "continuous": true }, "extract-i18n": { "executor": "@angular-devkit/build-angular:extract-i18n", "options": { - "browserTarget": "example-app:build" + "buildTarget": "example-app:build" } }, "lint": { - "executor": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": ["apps/example-app/**/*.ts", "apps/example-app/**/*.html", "apps/example-app/src/**/*.html"] - }, - "outputs": ["{options.outputFile}"] + "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nrwl/jest:jest", + "executor": "@nx/jest:jest", "options": { - "jestConfig": "apps/example-app/jest.config.js" + "jestConfig": "apps/example-app/jest.config.ts", + "passWithNoTests": false }, - "outputs": ["coverage/"] + "outputs": ["{workspaceRoot}/coverage/"] } - }, - "tags": [] + } } diff --git a/apps/example-app/src/app/app-routing.module.ts b/apps/example-app/src/app/app-routing.module.ts deleted file mode 100644 index a553ba61..00000000 --- a/apps/example-app/src/app/app-routing.module.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; - -import { SingleComponent } from './examples/00-single-component'; -import { NestedContainerComponent } from './examples/01-nested-component'; -import { InputOutputComponent } from './examples/02-input-output'; -import { FormsComponent } from './examples/03-forms'; -import { MaterialFormsComponent } from './examples/04-forms-with-material'; -import { ComponentWithProviderComponent } from './examples/05-component-provider'; -import { WithNgRxStoreComponent } from './examples/06-with-ngrx-store'; -import { WithNgRxMockStoreComponent } from './examples/07-with-ngrx-mock-store'; -import { RootComponent, DetailComponent, HiddenDetailComponent } from './examples/09-router'; - -export const examples = [ - { - path: 'single-component', - component: SingleComponent, - data: { - name: 'Single component', - }, - }, - { - path: 'nested-component', - component: NestedContainerComponent, - data: { - name: 'Nested components', - }, - }, - { - path: 'input-output', - component: InputOutputComponent, - data: { - name: 'Input and Output', - }, - }, - { - path: 'forms', - component: FormsComponent, - data: { - name: 'Form', - }, - }, - { - path: 'forms-material', - component: MaterialFormsComponent, - data: { - name: 'Material form', - }, - }, - { - path: 'component-with-provider', - component: ComponentWithProviderComponent, - data: { - name: 'With provider', - }, - }, - { - path: 'with-ngrx-store', - component: WithNgRxStoreComponent, - data: { - name: 'With NgRx Store', - }, - }, - { - path: 'with-ngrx-mock-store', - component: WithNgRxMockStoreComponent, - data: { - name: 'With NgRx MockStore', - }, - }, - { - path: 'with-router', - component: RootComponent, - data: { - name: 'Router', - }, - children: [ - { - path: 'detail/:id', - component: DetailComponent, - }, - { - path: 'hidden-detail', - component: HiddenDetailComponent, - }, - ], - }, -]; - -export const routes: Routes = [ - { - path: '', - children: examples, - }, -]; - -@NgModule({ - imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], - exports: [RouterModule], -}) -export class AppRoutingModule {} diff --git a/apps/example-app/src/app/app.component.css b/apps/example-app/src/app/app.component.css deleted file mode 100644 index 6d3bc67b..00000000 --- a/apps/example-app/src/app/app.component.css +++ /dev/null @@ -1,17 +0,0 @@ -.container { - display: flex; - flex-direction: column; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - -.sidenav { - flex: 1; -} - -.sidenav-container { - padding: 10px; -} diff --git a/apps/example-app/src/app/app.component.html b/apps/example-app/src/app/app.component.html deleted file mode 100644 index 04b5f476..00000000 --- a/apps/example-app/src/app/app.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
- -

@testing-library/angular

-
- - - - - {{ example.data.name }} - - - - - - - -
diff --git a/apps/example-app/src/app/app.component.ts b/apps/example-app/src/app/app.component.ts deleted file mode 100644 index 5b20ef63..00000000 --- a/apps/example-app/src/app/app.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { examples as routes } from './app-routing.module'; - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['app.component.css'], -}) -export class AppComponent { - examples = routes; -} diff --git a/apps/example-app/src/app/app.module.ts b/apps/example-app/src/app/app.module.ts deleted file mode 100644 index cb95434f..00000000 --- a/apps/example-app/src/app/app.module.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { NgModule } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; -import { StoreModule } from '@ngrx/store'; - -import { AppRoutingModule } from './app-routing.module'; -import { MaterialModule } from './material.module'; -import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { MatSidenavModule } from '@angular/material/sidenav'; -import { MatToolbarModule } from '@angular/material/toolbar'; - -import { AppComponent } from './app.component'; -import { SingleComponent } from './examples/00-single-component'; -import { NestedButtonComponent, NestedValueComponent, NestedContainerComponent } from './examples/01-nested-component'; -import { InputOutputComponent } from './examples/02-input-output'; -import { FormsComponent } from './examples/03-forms'; -import { MaterialFormsComponent } from './examples/04-forms-with-material'; -import { ComponentWithProviderComponent } from './examples/05-component-provider'; -import { WithNgRxStoreComponent, reducer } from './examples/06-with-ngrx-store'; -import { WithNgRxMockStoreComponent } from './examples/07-with-ngrx-mock-store'; -import { RootComponent, DetailComponent, HiddenDetailComponent } from './examples/09-router'; -import { ScrollingModule } from '@angular/cdk/scrolling'; - -function reducerItems() { - return ['One', 'Two', 'Three']; -} - -@NgModule({ - declarations: [ - AppComponent, - SingleComponent, - NestedButtonComponent, - NestedValueComponent, - NestedContainerComponent, - InputOutputComponent, - FormsComponent, - MaterialFormsComponent, - ComponentWithProviderComponent, - WithNgRxStoreComponent, - WithNgRxMockStoreComponent, - RootComponent, - DetailComponent, - HiddenDetailComponent, - ], - imports: [ - BrowserModule, - ReactiveFormsModule, - BrowserAnimationsModule, - MaterialModule, - MatIconModule, - MatListModule, - MatSidenavModule, - MatToolbarModule, - AppRoutingModule, - ScrollingModule, - StoreModule.forRoot({ - value: reducer, - items: reducerItems, - }), - ], - bootstrap: [AppComponent], -}) -export class AppModule {} diff --git a/apps/example-app/src/app/examples/00-single-component.spec.ts b/apps/example-app/src/app/examples/00-single-component.spec.ts index 73e429bb..44ad2500 100644 --- a/apps/example-app/src/app/examples/00-single-component.spec.ts +++ b/apps/example-app/src/app/examples/00-single-component.spec.ts @@ -1,8 +1,10 @@ -import { render, screen, fireEvent } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; import { SingleComponent } from './00-single-component'; test('renders the current value and can increment and decrement', async () => { + const user = userEvent.setup(); await render(SingleComponent); const incrementControl = screen.getByRole('button', { name: /increment/i }); @@ -11,10 +13,10 @@ test('renders the current value and can increment and decrement', async () => { expect(valueControl).toHaveTextContent('0'); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); expect(valueControl).toHaveTextContent('2'); - fireEvent.click(decrementControl); + await user.click(decrementControl); expect(valueControl).toHaveTextContent('1'); }); 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 25001036..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,8 @@ import { Component } from '@angular/core'; @Component({ - selector: 'app-fixture', + selector: 'atl-fixture', + standalone: true, template: ` {{ value }} diff --git a/apps/example-app/src/app/examples/01-nested-component.spec.ts b/apps/example-app/src/app/examples/01-nested-component.spec.ts index 8f3a242d..dfa3fe3f 100644 --- a/apps/example-app/src/app/examples/01-nested-component.spec.ts +++ b/apps/example-app/src/app/examples/01-nested-component.spec.ts @@ -1,11 +1,11 @@ -import { render, screen, fireEvent } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; -import { NestedButtonComponent, NestedValueComponent, NestedContainerComponent } from './01-nested-component'; +import { NestedContainerComponent } from './01-nested-component'; test('renders the current value and can increment and decrement', async () => { - await render(NestedContainerComponent, { - declarations: [NestedButtonComponent, NestedValueComponent], - }); + const user = userEvent.setup(); + await render(NestedContainerComponent); const incrementControl = screen.getByRole('button', { name: /increment/i }); const decrementControl = screen.getByRole('button', { name: /decrement/i }); @@ -13,10 +13,10 @@ test('renders the current value and can increment and decrement', async () => { expect(valueControl).toHaveTextContent('0'); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); expect(valueControl).toHaveTextContent('2'); - fireEvent.click(decrementControl); + await user.click(decrementControl); expect(valueControl).toHaveTextContent('1'); }); 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 51087b44..fd0d0c0e 100644 --- a/apps/example-app/src/app/examples/01-nested-component.ts +++ b/apps/example-app/src/app/examples/01-nested-component.ts @@ -1,7 +1,8 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; @Component({ - selector: 'app-button', + standalone: true, + selector: 'atl-button', template: ' ', }) export class NestedButtonComponent { @@ -10,7 +11,8 @@ export class NestedButtonComponent { } @Component({ - selector: 'app-value', + standalone: true, + selector: 'atl-value', template: ' {{ value }} ', }) export class NestedValueComponent { @@ -18,12 +20,14 @@ export class NestedValueComponent { } @Component({ - selector: 'app-fixture', + standalone: true, + selector: 'atl-fixture', template: ` - - - + + + `, + imports: [NestedButtonComponent, NestedValueComponent], }) export class NestedContainerComponent { value = 0; 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 f1bd4a1c..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 @@ -1,13 +1,73 @@ -import { render, screen, fireEvent } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; import { InputOutputComponent } from './02-input-output'; test('is possible to set input and listen for output', async () => { + const user = userEvent.setup(); const sendValue = jest.fn(); await render(InputOutputComponent, { - componentProperties: { + 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: { sendValue: { emit: sendValue, } as any, @@ -20,21 +80,22 @@ test('is possible to set input and listen for output', async () => { expect(valueControl).toHaveTextContent('47'); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); expect(valueControl).toHaveTextContent('50'); - fireEvent.click(sendControl); + await user.click(sendControl); expect(sendValue).toHaveBeenCalledTimes(1); 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('', { - declarations: [InputOutputComponent], + await render('', { + imports: [InputOutputComponent], componentProperties: { sendValue: sendSpy, }, @@ -46,12 +107,12 @@ test('is possible to set input and listen for output with the template syntax', expect(valueControl).toHaveTextContent('47'); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); expect(valueControl).toHaveTextContent('50'); - fireEvent.click(sendControl); + await user.click(sendControl); expect(sendSpy).toHaveBeenCalledTimes(1); expect(sendSpy).toHaveBeenCalledWith(50); }); 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 a7ef9ce4..3d7f9796 100644 --- a/apps/example-app/src/app/examples/02-input-output.ts +++ b/apps/example-app/src/app/examples/02-input-output.ts @@ -1,7 +1,8 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; @Component({ - selector: 'app-fixture', + standalone: true, + selector: 'atl-fixture', template: ` {{ value }} diff --git a/apps/example-app/src/app/examples/03-forms.spec.ts b/apps/example-app/src/app/examples/03-forms.spec.ts index 2a53a5f0..0e475834 100644 --- a/apps/example-app/src/app/examples/03-forms.spec.ts +++ b/apps/example-app/src/app/examples/03-forms.spec.ts @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { FormsComponent } from './03-forms'; test('is possible to fill in a form and verify error messages (with the help of jest-dom https://testing-library.com/docs/ecosystem-jest-dom)', async () => { + const user = userEvent.setup(); await render(FormsComponent); const nameControl = screen.getByRole('textbox', { name: /name/i }); @@ -16,19 +17,19 @@ test('is possible to fill in a form and verify error messages (with the help of expect(errors).toContainElement(screen.queryByText('color is required')); expect(nameControl).toBeInvalid(); - userEvent.type(nameControl, 'Tim'); - userEvent.clear(scoreControl); - userEvent.type(scoreControl, '12'); + await user.type(nameControl, 'Tim'); + await user.clear(scoreControl); + await user.type(scoreControl, '12'); fireEvent.blur(scoreControl); - userEvent.selectOptions(colorControl, 'G'); + await user.selectOptions(colorControl, 'G'); expect(screen.queryByText('name is required')).not.toBeInTheDocument(); expect(screen.getByText('score must be lesser than 10')).toBeInTheDocument(); expect(screen.queryByText('color is required')).not.toBeInTheDocument(); expect(scoreControl).toBeInvalid(); - userEvent.clear(scoreControl); - userEvent.type(scoreControl, '7'); + await user.clear(scoreControl); + await user.type(scoreControl, '7'); fireEvent.blur(scoreControl); expect(scoreControl).toBeValid(); diff --git a/apps/example-app/src/app/examples/03-forms.ts b/apps/example-app/src/app/examples/03-forms.ts index df861a3c..c1e48c23 100644 --- a/apps/example-app/src/app/examples/03-forms.ts +++ b/apps/example-app/src/app/examples/03-forms.ts @@ -1,8 +1,11 @@ -import { Component } from '@angular/core'; -import { UntypedFormBuilder, Validators } from '@angular/forms'; +import { NgForOf, NgIf } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; @Component({ - selector: 'app-fixture', + standalone: true, + selector: 'atl-fixture', + imports: [ReactiveFormsModule, NgForOf, NgIf], template: `
@@ -30,19 +33,20 @@ import { UntypedFormBuilder, Validators } from '@angular/forms'; `, }) export class FormsComponent { + private formBuilder = inject(FormBuilder); + colors = [ { id: 'R', value: 'Red' }, { id: 'B', value: 'Blue' }, { id: 'G', value: 'Green' }, ]; + form = this.formBuilder.group({ - name: ['', Validators.required], + name: ['', [Validators.required]], score: [0, { validators: [Validators.min(1), Validators.max(10)], updateOn: 'blur' }], - color: ['', Validators.required], + color: [null as string | null, Validators.required], }); - constructor(private formBuilder: UntypedFormBuilder) {} - get formErrors() { return Object.keys(this.form.controls) .map((formKey) => { diff --git a/apps/example-app/src/app/examples/04-forms-with-material.spec.ts b/apps/example-app/src/app/examples/04-forms-with-material.spec.ts index 6d7e1e18..638d76ff 100644 --- a/apps/example-app/src/app/examples/04-forms-with-material.spec.ts +++ b/apps/example-app/src/app/examples/04-forms-with-material.spec.ts @@ -1,76 +1,101 @@ import { render, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; -import { MaterialModule } from '../material.module'; import { MaterialFormsComponent } from './04-forms-with-material'; test('is possible to fill in a form and verify error messages (with the help of jest-dom https://testing-library.com/docs/ecosystem-jest-dom)', async () => { - const { fixture } = await render(MaterialFormsComponent, { - imports: [MaterialModule], - }); + const user = userEvent.setup(); + + const { fixture } = await render(MaterialFormsComponent); const nameControl = screen.getByLabelText(/name/i); const scoreControl = screen.getByRole('spinbutton', { name: /score/i }); - const colorControl = screen.getByRole('combobox', { name: /color/i }); + const colorControl = screen.getByPlaceholderText(/color/i); + const dateControl = screen.getByRole('textbox', { name: /Choose a date/i }); + const checkboxControl = screen.getByRole('checkbox', { name: /agree/i }); + const errors = screen.getByRole('alert'); expect(errors).toContainElement(screen.queryByText('name is required')); expect(errors).toContainElement(screen.queryByText('score must be greater than 1')); expect(errors).toContainElement(screen.queryByText('color is required')); + expect(errors).toContainElement(screen.queryByText('agree is required')); - userEvent.type(nameControl, 'Tim'); - userEvent.clear(scoreControl); - userEvent.type(scoreControl, '12'); - userEvent.click(colorControl); - userEvent.click(screen.getByText(/green/i)); + await user.type(nameControl, 'Tim'); + await user.clear(scoreControl); + await user.type(scoreControl, '12'); + await user.click(colorControl); + await user.click(screen.getByText(/green/i)); + + expect(checkboxControl).not.toBeChecked(); + await user.click(checkboxControl); + expect(checkboxControl).toBeChecked(); + expect(checkboxControl).toBeValid(); expect(screen.queryByText('name is required')).not.toBeInTheDocument(); expect(screen.getByText('score must be lesser than 10')).toBeInTheDocument(); expect(screen.queryByText('color is required')).not.toBeInTheDocument(); + expect(screen.queryByText('agree is required')).not.toBeInTheDocument(); expect(scoreControl).toBeInvalid(); - userEvent.clear(scoreControl); - userEvent.type(scoreControl, '7'); + await user.clear(scoreControl); + await user.type(scoreControl, '7'); expect(scoreControl).toBeValid(); + await user.type(dateControl, '08/11/2022'); + expect(errors).not.toBeInTheDocument(); expect(nameControl).toHaveValue('Tim'); expect(scoreControl).toHaveValue(7); expect(colorControl).toHaveTextContent('Green'); + expect(checkboxControl).toBeChecked(); const form = screen.getByRole('form'); expect(form).toHaveFormValues({ name: 'Tim', score: 7, }); + + // material doesn't add these to the form + expect((fixture.componentInstance as MaterialFormsComponent).form?.get('agree')?.value).toBe(true); expect((fixture.componentInstance as MaterialFormsComponent).form?.get('color')?.value).toBe('G'); + expect((fixture.componentInstance as MaterialFormsComponent).form?.get('date')?.value).toEqual(new Date(2022, 7, 11)); }); test('set and show pre-set form values', async () => { - const { fixture, detectChanges } = await render(MaterialFormsComponent, { - imports: [MaterialModule], - }); + const user = userEvent.setup(); + + const { fixture, detectChanges } = await render(MaterialFormsComponent); fixture.componentInstance.form.setValue({ name: 'Max', score: 4, color: 'B', + date: new Date(2022, 7, 11), + agree: true, }); detectChanges(); const nameControl = screen.getByLabelText(/name/i); const scoreControl = screen.getByRole('spinbutton', { name: /score/i }); - const colorControl = screen.getByRole('combobox', { name: /color/i }); + const colorControl = screen.getByPlaceholderText(/color/i); + const checkboxControl = screen.getByRole('checkbox', { name: /agree/i }); expect(nameControl).toHaveValue('Max'); expect(scoreControl).toHaveValue(4); expect(colorControl).toHaveTextContent('Blue'); + expect(checkboxControl).toBeChecked(); + await user.click(checkboxControl); const form = screen.getByRole('form'); expect(form).toHaveFormValues({ name: 'Max', score: 4, }); + + // material doesn't add these to the form + expect((fixture.componentInstance as MaterialFormsComponent).form?.get('agree')?.value).toBe(false); expect((fixture.componentInstance as MaterialFormsComponent).form?.get('color')?.value).toBe('B'); + expect((fixture.componentInstance as MaterialFormsComponent).form?.get('date')?.value).toEqual(new Date(2022, 7, 11)); }); 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 ed33dd3f..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,15 +1,35 @@ -import { Component } from '@angular/core'; -import { UntypedFormBuilder, Validators } from '@angular/forms'; - +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'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatNativeDateModule } from '@angular/material/core'; @Component({ - selector: 'app-fixture', + standalone: true, + imports: [ + MatInputModule, + MatSelectModule, + MatDatepickerModule, + MatNativeDateModule, + MatCheckboxModule, + ReactiveFormsModule, + NgForOf, + NgIf, + ], + selector: 'atl-fixture', template: ` + Name + I Agree + + Score + Color {{ colorControlDisplayValue }} @@ -32,6 +53,14 @@ import { UntypedFormBuilder, Validators } from '@angular/forms'; + + Choose a date + + MM/DD/YYYY + + + +

{{ error }}

@@ -55,22 +84,24 @@ import { UntypedFormBuilder, Validators } from '@angular/forms'; ], }) export class MaterialFormsComponent { + private formBuilder = inject(FormBuilder); + colors = [ { id: 'R', value: 'Red' }, { id: 'B', value: 'Blue' }, { id: 'G', value: 'Green' }, ]; form = this.formBuilder.group({ - name: ['', Validators.required], + name: ['', [Validators.required]], score: [0, [Validators.min(1), Validators.max(10)]], - color: [null, Validators.required], + color: [null as string | null, Validators.required], + date: [null as Date | null, Validators.required], + agree: [false, Validators.requiredTrue], }); - constructor(private formBuilder: UntypedFormBuilder) {} - get colorControlDisplayValue(): string | undefined { const selectedId = this.form.get('color')?.value; - return this.colors.filter(color => color.id === selectedId)[0]?.value; + return this.colors.filter((color) => color.id === selectedId)[0]?.value; } get formErrors() { diff --git a/apps/example-app/src/app/examples/05-component-provider.spec.ts b/apps/example-app/src/app/examples/05-component-provider.spec.ts index 79811245..d23e849d 100644 --- a/apps/example-app/src/app/examples/05-component-provider.spec.ts +++ b/apps/example-app/src/app/examples/05-component-provider.spec.ts @@ -1,10 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { render, screen, fireEvent } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; import { provideMock, Mock, createMock } from '@testing-library/angular/jest-utils'; +import userEvent from '@testing-library/user-event'; import { ComponentWithProviderComponent, CounterService } from './05-component-provider'; test('renders the current value and can increment and decrement', async () => { + const user = userEvent.setup(); + await render(ComponentWithProviderComponent, { componentProviders: [ { @@ -20,15 +23,17 @@ test('renders the current value and can increment and decrement', async () => { expect(valueControl).toHaveTextContent('0'); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); expect(valueControl).toHaveTextContent('2'); - fireEvent.click(decrementControl); + await user.click(decrementControl); expect(valueControl).toHaveTextContent('1'); }); test('renders the current value and can increment and decrement with a mocked jest-utils service', async () => { + const user = userEvent.setup(); + const counter = createMock(CounterService); let fakeCounterValue = 50; counter.increment.mockImplementation(() => (fakeCounterValue += 10)); @@ -50,15 +55,17 @@ test('renders the current value and can increment and decrement with a mocked je expect(valueControl).toHaveTextContent('50'); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); expect(valueControl).toHaveTextContent('70'); - fireEvent.click(decrementControl); + await user.click(decrementControl); expect(valueControl).toHaveTextContent('60'); }); test('renders the current value and can increment and decrement with provideMocked from jest-utils', async () => { + const user = userEvent.setup(); + await render(ComponentWithProviderComponent, { componentProviders: [provideMock(CounterService)], }); @@ -66,9 +73,9 @@ test('renders the current value and can increment and decrement with provideMock const incrementControl = screen.getByRole('button', { name: /increment/i }); const decrementControl = screen.getByRole('button', { name: /decrement/i }); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); - fireEvent.click(decrementControl); + await user.click(incrementControl); + await user.click(incrementControl); + await user.click(decrementControl); const counterService = TestBed.inject(CounterService) as Mock; expect(counterService.increment).toHaveBeenCalledTimes(2); 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 4d933dbb..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', @@ -20,7 +20,8 @@ export class CounterService { } @Component({ - selector: 'app-fixture', + standalone: true, + selector: 'atl-fixture', template: ` {{ counter.value() }} @@ -29,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.spec.ts b/apps/example-app/src/app/examples/06-with-ngrx-store.spec.ts index b8a289bb..0f080658 100644 --- a/apps/example-app/src/app/examples/06-with-ngrx-store.spec.ts +++ b/apps/example-app/src/app/examples/06-with-ngrx-store.spec.ts @@ -1,9 +1,12 @@ -import { render, screen, fireEvent } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; import { StoreModule } from '@ngrx/store'; +import userEvent from '@testing-library/user-event'; import { WithNgRxStoreComponent, reducer } from './06-with-ngrx-store'; test('works with ngrx store', async () => { + const user = userEvent.setup(); + await render(WithNgRxStoreComponent, { imports: [ StoreModule.forRoot( @@ -23,10 +26,10 @@ test('works with ngrx store', async () => { expect(valueControl).toHaveTextContent('0'); - fireEvent.click(incrementControl); - fireEvent.click(incrementControl); + await user.click(incrementControl); + await user.click(incrementControl); expect(valueControl).toHaveTextContent('20'); - fireEvent.click(decrementControl); + await user.click(decrementControl); expect(valueControl).toHaveTextContent('10'); }); 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 470f52d9..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,4 +1,5 @@ -import { Component } from '@angular/core'; +import { AsyncPipe } from '@angular/common'; +import { Component, inject } from '@angular/core'; import { createSelector, Store, createAction, createReducer, on, select } from '@ngrx/store'; const increment = createAction('increment'); @@ -15,7 +16,9 @@ const selectValue = createSelector( ); @Component({ - selector: 'app-fixture', + standalone: true, + imports: [AsyncPipe], + selector: 'atl-fixture', template: ` {{ value | async }} @@ -23,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.spec.ts b/apps/example-app/src/app/examples/07-with-ngrx-mock-store.spec.ts index 936168e5..eb51dbbc 100644 --- a/apps/example-app/src/app/examples/07-with-ngrx-mock-store.spec.ts +++ b/apps/example-app/src/app/examples/07-with-ngrx-mock-store.spec.ts @@ -1,10 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { provideMockStore, MockStore } from '@ngrx/store/testing'; -import { render, screen, fireEvent } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; import { WithNgRxMockStoreComponent, selectItems } from './07-with-ngrx-mock-store'; test('works with provideMockStore', async () => { + const user = userEvent.setup(); + await render(WithNgRxMockStoreComponent, { providers: [ provideMockStore({ @@ -21,7 +24,7 @@ test('works with provideMockStore', async () => { const store = TestBed.inject(MockStore); store.dispatch = jest.fn(); - fireEvent.click(screen.getByText(/seven/i)); + await user.click(screen.getByText(/seven/i)); expect(store.dispatch).toHaveBeenCalledWith({ type: '[Item List] send', item: 'Seven' }); }); 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 249caf21..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,4 +1,5 @@ -import { Component } from '@angular/core'; +import { AsyncPipe, NgForOf } from '@angular/common'; +import { Component, inject } from '@angular/core'; import { createSelector, Store, select } from '@ngrx/store'; export const selectItems = createSelector( @@ -7,16 +8,21 @@ export const selectItems = createSelector( ); @Component({ - selector: 'app-fixture', + standalone: true, + imports: [AsyncPipe, NgForOf], + 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 efd2a5b9..28a41e98 100644 --- a/apps/example-app/src/app/examples/08-directive.spec.ts +++ b/apps/example-app/src/app/examples/08-directive.spec.ts @@ -1,10 +1,39 @@ -import { render, screen, fireEvent } from '@testing-library/angular'; +import { Component } from '@angular/core'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; import { SpoilerDirective } from './08-directive'; +test('it is possible to test directives with container component', async () => { + @Component({ + template: `
`, + imports: [SpoilerDirective], + standalone: true, + }) + class FixtureComponent {} + + const user = userEvent.setup(); + await render(FixtureComponent); + + const directive = screen.getByTestId('dir'); + + expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument(); + expect(screen.getByText('SPOILER')).toBeInTheDocument(); + + await user.hover(directive); + expect(screen.queryByText('SPOILER')).not.toBeInTheDocument(); + expect(screen.getByText('I am visible now...')).toBeInTheDocument(); + + await user.unhover(directive); + expect(screen.getByText('SPOILER')).toBeInTheDocument(); + expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument(); +}); + test('it is possible to test directives', async () => { - await render('
', { - declarations: [SpoilerDirective], + const user = userEvent.setup(); + + await render('
', { + imports: [SpoilerDirective], }); const directive = screen.getByTestId('dir'); @@ -12,21 +41,22 @@ test('it is possible to test directives', async () => { expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument(); expect(screen.getByText('SPOILER')).toBeInTheDocument(); - fireEvent.mouseOver(directive); + await user.hover(directive); expect(screen.queryByText('SPOILER')).not.toBeInTheDocument(); expect(screen.getByText('I am visible now...')).toBeInTheDocument(); - fireEvent.mouseLeave(directive); + await user.unhover(directive); expect(screen.getByText('SPOILER')).toBeInTheDocument(); expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument(); }); test('it is possible to test directives with props', async () => { + const user = userEvent.setup(); const hidden = 'SPOILER ALERT'; const visible = 'There is nothing to see here ...'; - await render('
', { - declarations: [SpoilerDirective], + await render('
', { + imports: [SpoilerDirective], componentProperties: { hidden, visible, @@ -36,31 +66,32 @@ test('it is possible to test directives with props', async () => { expect(screen.queryByText(visible)).not.toBeInTheDocument(); expect(screen.getByText(hidden)).toBeInTheDocument(); - fireEvent.mouseOver(screen.getByText(hidden)); + await user.hover(screen.getByText(hidden)); expect(screen.queryByText(hidden)).not.toBeInTheDocument(); expect(screen.getByText(visible)).toBeInTheDocument(); - fireEvent.mouseLeave(screen.getByText(visible)); + await user.unhover(screen.getByText(visible)); expect(screen.getByText(hidden)).toBeInTheDocument(); expect(screen.queryByText(visible)).not.toBeInTheDocument(); }); test('it is possible to test directives with props in template', async () => { + const user = userEvent.setup(); const hidden = 'SPOILER ALERT'; const visible = 'There is nothing to see here ...'; - await render(``, { - declarations: [SpoilerDirective], + await render(``, { + imports: [SpoilerDirective], }); expect(screen.queryByText(visible)).not.toBeInTheDocument(); expect(screen.getByText(hidden)).toBeInTheDocument(); - fireEvent.mouseOver(screen.getByText(hidden)); + await user.hover(screen.getByText(hidden)); expect(screen.queryByText(hidden)).not.toBeInTheDocument(); expect(screen.getByText(visible)).toBeInTheDocument(); - fireEvent.mouseLeave(screen.getByText(visible)); + await user.unhover(screen.getByText(visible)); expect(screen.getByText(hidden)).toBeInTheDocument(); expect(screen.queryByText(visible)).not.toBeInTheDocument(); }); diff --git a/apps/example-app/src/app/examples/08-directive.ts b/apps/example-app/src/app/examples/08-directive.ts index 12a029cf..d6cd631c 100644 --- a/apps/example-app/src/app/examples/08-directive.ts +++ b/apps/example-app/src/app/examples/08-directive.ts @@ -1,14 +1,15 @@ -import { Directive, HostListener, ElementRef, Input, OnInit } from '@angular/core'; +import { Directive, HostListener, ElementRef, Input, OnInit, inject } from '@angular/core'; @Directive({ - selector: '[appSpoiler]', + standalone: true, + selector: '[atlSpoiler]', }) export class SpoilerDirective implements OnInit { + private el = inject(ElementRef); + @Input() hidden = 'SPOILER'; @Input() visible = 'I am visible now...'; - constructor(private el: ElementRef) {} - ngOnInit() { this.el.nativeElement.textContent = this.hidden; } diff --git a/apps/example-app/src/app/examples/09-router.spec.ts b/apps/example-app/src/app/examples/09-router.spec.ts index 1e2be51b..f1da85d2 100644 --- a/apps/example-app/src/app/examples/09-router.spec.ts +++ b/apps/example-app/src/app/examples/09-router.spec.ts @@ -1,11 +1,11 @@ -import { render, screen, waitForElementToBeRemoved } from '@testing-library/angular'; +import { render, screen } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; import { DetailComponent, RootComponent, HiddenDetailComponent } from './09-router'; test('it can navigate to routes', async () => { + const user = userEvent.setup(); await render(RootComponent, { - declarations: [DetailComponent, HiddenDetailComponent], routes: [ { path: '', @@ -25,26 +25,25 @@ test('it can navigate to routes', async () => { expect(screen.queryByText(/Detail one/i)).not.toBeInTheDocument(); - userEvent.click(screen.getByRole('link', { name: /load one/i })); + await user.click(screen.getByRole('link', { name: /load one/i })); expect(await screen.findByRole('heading', { name: /Detail one/i })).toBeInTheDocument(); - userEvent.click(screen.getByRole('link', { name: /load three/i })); - await waitForElementToBeRemoved(() => screen.queryByRole('heading', { name: /Detail one/i })); + await user.click(screen.getByRole('link', { name: /load three/i })); + expect(screen.queryByRole('heading', { name: /Detail one/i })).not.toBeInTheDocument(); expect(await screen.findByRole('heading', { name: /Detail three/i })).toBeInTheDocument(); - userEvent.click(screen.getByRole('link', { name: /back to parent/i })); - await waitForElementToBeRemoved(() => screen.queryByRole('heading', { name: /Detail three/i })); + await user.click(screen.getByRole('link', { name: /back to parent/i })); + expect(screen.queryByRole('heading', { name: /Detail three/i })).not.toBeInTheDocument(); - userEvent.click(screen.getByRole('link', { name: /load two/i })); + await user.click(screen.getByRole('link', { name: /load two/i })); expect(await screen.findByRole('heading', { name: /Detail two/i })).toBeInTheDocument(); - userEvent.click(screen.getByRole('link', { name: /hidden x/i })); + await user.click(screen.getByRole('link', { name: /hidden x/i })); expect(await screen.findByText(/You found the treasure!/i)).toBeInTheDocument(); }); test('it can navigate to routes - workaround', async () => { const { navigate } = await render(RootComponent, { - declarations: [DetailComponent, HiddenDetailComponent], routes: [ { path: '', @@ -83,7 +82,6 @@ test('it can navigate to routes - workaround', async () => { test('it can navigate to routes with a base path', async () => { const basePath = 'base'; const { navigate } = await render(RootComponent, { - declarations: [DetailComponent, HiddenDetailComponent], routes: [ { path: basePath, diff --git a/apps/example-app/src/app/examples/09-router.ts b/apps/example-app/src/app/examples/09-router.ts index 6ea0df73..f29a4efe 100644 --- a/apps/example-app/src/app/examples/09-router.ts +++ b/apps/example-app/src/app/examples/09-router.ts @@ -1,40 +1,46 @@ -import { Component } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { AsyncPipe } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, RouterLink, RouterOutlet } from '@angular/router'; import { map } from 'rxjs/operators'; @Component({ - selector: 'app-main', + standalone: true, + imports: [RouterLink, RouterOutlet], + selector: 'atl-main', template: ` - Load one | Load two | - Load three | + Load one | Load two | + Load three |
- + `, }) export class RootComponent {} @Component({ - selector: 'app-detail', + standalone: true, + imports: [RouterLink, AsyncPipe], + selector: 'atl-detail', template: `

Detail {{ id | async }}

{{ text | async }} {{ subtext | async }}

- Back to parent + Back to parent hidden x `, }) 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({ - selector: 'app-detail-hidden', + standalone: true, + 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 8cc843e5..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,11 +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({ - selector: 'app-fixture', + standalone: true, + 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 f061e862..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 @@ -4,9 +4,9 @@ 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}`, { - declarations: [CellComponent] + + await render(`${projection}`, { + imports: [CellComponent], }); expect(screen.getByText(projection)).toBeInTheDocument(); 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 47845919..0dd668bc 100644 --- a/apps/example-app/src/app/examples/11-ng-content.ts +++ b/apps/example-app/src/app/examples/11-ng-content.ts @@ -1,7 +1,8 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; @Component({ - selector: 'app-fixture', + standalone: true, + 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 d272e270..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,4 +1,5 @@ -import { Component, Injectable } from '@angular/core'; +import { AsyncPipe, NgForOf } from '@angular/common'; +import { Component, inject, Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; export class Customer { @@ -16,7 +17,9 @@ export class CustomersService { } @Component({ - selector: 'app-fixture', + standalone: true, + imports: [AsyncPipe, NgForOf], + selector: 'atl-fixture', template: `

  • @@ -26,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.spec.ts b/apps/example-app/src/app/examples/13-scrolling.component.spec.ts index 4abaed08..cb1ad11b 100644 --- a/apps/example-app/src/app/examples/13-scrolling.component.spec.ts +++ b/apps/example-app/src/app/examples/13-scrolling.component.spec.ts @@ -1,12 +1,9 @@ import { render, screen, waitForElementToBeRemoved } from '@testing-library/angular'; import { CdkVirtualScrollOverviewExampleComponent } from './13-scrolling.component'; -import { ScrollingModule } from '@angular/cdk/scrolling'; test('should scroll to load more items', async () => { - await render(CdkVirtualScrollOverviewExampleComponent, { - imports: [ScrollingModule], - }); + await render(CdkVirtualScrollOverviewExampleComponent); const item0 = await screen.findByText(/Item #0/i); expect(item0).toBeVisible(); 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 0080c4e8..6a36ed8f 100644 --- a/apps/example-app/src/app/examples/13-scrolling.component.ts +++ b/apps/example-app/src/app/examples/13-scrolling.component.ts @@ -1,7 +1,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ScrollingModule } from '@angular/cdk/scrolling'; @Component({ - selector: 'app-cdk-virtual-scroll-overview-example', + standalone: true, + imports: [ScrollingModule], + 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 ba72ce70..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,7 @@ import { render, screen, fireEvent } from '@testing-library/angular'; import { AsyncComponent } from './14-async-component'; -test('can use fakeAsync utilities', fakeAsync(async () => { +test.skip('can use fakeAsync utilities', fakeAsync(async () => { await render(AsyncComponent); const load = await screen.findByRole('button', { name: /load/i }); @@ -20,6 +20,8 @@ test('can use fakeTimer utilities', async () => { await render(AsyncComponent); const load = await screen.findByRole('button', { name: /load/i }); + + // userEvent not working with fake timers fireEvent.click(load); jest.advanceTimersByTime(10_000); 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 f8521539..64d7aaa2 100644 --- a/apps/example-app/src/app/examples/14-async-component.ts +++ b/apps/example-app/src/app/examples/14-async-component.ts @@ -1,9 +1,12 @@ +import { AsyncPipe, NgIf } from '@angular/common'; import { Component, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; import { delay, filter, mapTo } from 'rxjs/operators'; @Component({ - selector: 'app-fixture', + standalone: true, + imports: [AsyncPipe, NgIf], + 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 31cf2fba..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,14 +1,17 @@ -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { render, screen, waitForElementToBeRemoved, fireEvent } from '@testing-library/angular'; +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'; -import { DialogComponent, DialogContentComponent, DialogContentComponentModule } from './15-dialog.component'; +import { DialogComponent, DialogContentComponent } from './15-dialog.component'; test('dialog closes', async () => { + const user = userEvent.setup(); + const closeFn = jest.fn(); await render(DialogContentComponent, { - imports: [MatDialogModule], providers: [ + provideNoopAnimations(), { provide: MatDialogRef, useValue: { @@ -19,42 +22,44 @@ test('dialog closes', async () => { }); const cancelButton = await screen.findByRole('button', { name: /cancel/i }); - userEvent.click(cancelButton); + await user.click(cancelButton); expect(closeFn).toHaveBeenCalledTimes(1); }); test('closes the dialog via the backdrop', async () => { + const user = userEvent.setup(); + await render(DialogComponent, { - imports: [MatDialogModule, DialogContentComponentModule], + providers: [provideNoopAnimations()], }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); - userEvent.click(openDialogButton); + await user.click(openDialogButton); const dialogControl = await screen.findByRole('dialog'); expect(dialogControl).toBeInTheDocument(); const dialogTitleControl = await screen.findByRole('heading', { name: /dialog title/i }); expect(dialogTitleControl).toBeInTheDocument(); - // using fireEvent because of: - // unable to click element as it has or inherits pointer-events set to "none" - // eslint-disable-next-line testing-library/no-node-access, @typescript-eslint/no-non-null-assertion - fireEvent.click(document.querySelector('.cdk-overlay-backdrop')!); + // eslint-disable-next-line testing-library/no-node-access + await user.click(document.querySelector('.cdk-overlay-backdrop')!); - await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); const dialogTitle = screen.queryByRole('heading', { name: /dialog title/i }); expect(dialogTitle).not.toBeInTheDocument(); }); test('opens and closes the dialog with buttons', async () => { + const user = userEvent.setup(); + await render(DialogComponent, { - imports: [MatDialogModule, DialogContentComponentModule], + providers: [provideNoopAnimations()], }); const openDialogButton = await screen.findByRole('button', { name: /open dialog/i }); - userEvent.click(openDialogButton); + await user.click(openDialogButton); const dialogControl = await screen.findByRole('dialog'); expect(dialogControl).toBeInTheDocument(); @@ -62,9 +67,9 @@ test('opens and closes the dialog with buttons', async () => { expect(dialogTitleControl).toBeInTheDocument(); const cancelButton = await screen.findByRole('button', { name: /cancel/i }); - userEvent.click(cancelButton); + await user.click(cancelButton); - await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); const dialogTitle = screen.queryByRole('heading', { name: /dialog title/i }); expect(dialogTitle).not.toBeInTheDocument(); diff --git a/apps/example-app/src/app/examples/15-dialog.component.ts b/apps/example-app/src/app/examples/15-dialog.component.ts index af5d8d89..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,12 +1,14 @@ -import { Component, NgModule } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Component, inject } from '@angular/core'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @Component({ - selector: 'app-dialog-overview-example', + standalone: true, + imports: [MatDialogModule], + selector: 'atl-dialog-overview-example', template: '', }) export class DialogComponent { - constructor(public dialog: MatDialog) {} + private dialog = inject(MatDialog); openDialog(): void { this.dialog.open(DialogContentComponent); @@ -14,7 +16,9 @@ export class DialogComponent { } @Component({ - selector: 'app-dialog-overview-example-dialog', + standalone: true, + imports: [MatDialogModule], + selector: 'atl-dialog-overview-example-dialog', template: `

    Dialog Title

    Dialog content
    @@ -25,14 +29,9 @@ export class DialogComponent { `, }) export class DialogContentComponent { - constructor(public dialogRef: MatDialogRef) {} + private dialogRef = inject>(MatDialogRef); cancel(): void { this.dialogRef.close(); } } - -@NgModule({ - declarations: [DialogContentComponent], -}) -export class DialogContentComponentModule {} diff --git a/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts b/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts index 1d87edf9..4382d851 100644 --- a/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts +++ b/apps/example-app/src/app/examples/16-input-getter-setter.spec.ts @@ -10,27 +10,13 @@ test('should run logic in the input setter and getter', async () => { expect(getterValueControl).toHaveTextContent('I am value from getter Angular'); }); -test('should run logic in the input setter and getter while changing', async () => { - const { change } = await render(InputGetterSetter, { componentProperties: { value: 'Angular' } }); - const valueControl = screen.getByTestId('value'); - const getterValueControl = screen.getByTestId('value-getter'); - - expect(valueControl).toHaveTextContent('I am value from setter Angular'); - expect(getterValueControl).toHaveTextContent('I am value from getter Angular'); - - await change({ value: 'React' }); - - expect(valueControl).toHaveTextContent('I am value from setter React'); - expect(getterValueControl).toHaveTextContent('I am value from getter React'); -}); - test('should run logic in the input setter and getter while re-rendering', async () => { const { rerender } = await render(InputGetterSetter, { componentProperties: { value: 'Angular' } }); expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter Angular'); expect(screen.getByTestId('value-getter')).toHaveTextContent('I am value from getter Angular'); - await rerender({ value: 'React' }); + await rerender({ componentProperties: { value: 'React' } }); // note we have to re-query because the elements are not the same anymore expect(screen.getByTestId('value')).toHaveTextContent('I am value from setter React'); 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 22d9641a..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 @@ -1,13 +1,13 @@ import { Component, Input } from '@angular/core'; @Component({ - selector: 'app-fixture', + standalone: true, + 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 cd6334d1..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 @@ -1,12 +1,15 @@ import { render, screen } from '@testing-library/angular'; -import {ComponentWithAttributeSelectorComponent} from './17-component-with-attribute-selector'; +import { ComponentWithAttributeSelectorComponent } from './17-component-with-attribute-selector'; // Note: At this stage it is not possible to use the render(ComponentWithAttributeSelectorComponent, {...}) syntax // for components with attribute selectors! test('is possible to set input of component with attribute selector through template', async () => { - await render(``, { - declarations: [ComponentWithAttributeSelectorComponent] - }); + await render( + ``, + { + imports: [ComponentWithAttributeSelectorComponent], + }, + ); const valueControl = screen.getByTestId('value'); 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 2d0b904b..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 @@ -1,10 +1,9 @@ import { Component, Input } from '@angular/core'; @Component({ - selector: 'app-fixture-component-with-attribute-selector[value]', - template: ` - {{ value }} - `, + standalone: true, + selector: 'atl-fixture-component-with-attribute-selector[value]', + template: ` {{ value }} `, }) export class ComponentWithAttributeSelectorComponent { @Input() value!: number; diff --git a/apps/example-app/src/app/examples/18-html-as-input.spec.ts b/apps/example-app/src/app/examples/18-html-as-input.spec.ts index 5a56b412..068a8c09 100644 --- a/apps/example-app/src/app/examples/18-html-as-input.spec.ts +++ b/apps/example-app/src/app/examples/18-html-as-input.spec.ts @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/angular'; import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ + standalone: true, name: 'stripHTML', }) class StripHTMLPipe implements PipeTransform { @@ -19,18 +20,16 @@ test('passes HTML as component properties', async () => { componentProperties: { stringWithHtml: STRING_WITH_HTML, }, - declarations: [StripHTMLPipe], + imports: [StripHTMLPipe], }); expect(screen.getByText('Some database field with stripped HTML')).toBeInTheDocument(); }); - test('throws when passed HTML is passed in directly', async () => { await expect(() => render(`

    {{ '${STRING_WITH_HTML}' | stripHTML }}

    `, { - declarations: [StripHTMLPipe], + imports: [StripHTMLPipe], }), ).rejects.toThrow(); }); - diff --git a/apps/example-app/src/app/examples/19-standalone-component.spec.ts b/apps/example-app/src/app/examples/19-standalone-component.spec.ts index b21b3d7a..d1d1e0ba 100644 --- a/apps/example-app/src/app/examples/19-standalone-component.spec.ts +++ b/apps/example-app/src/app/examples/19-standalone-component.spec.ts @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/angular'; import { StandaloneComponent, StandaloneWithChildComponent } from './19-standalone-component'; -test('is possible to render a standalone component', async () => { +test('can render a standalone component', async () => { await render(StandaloneComponent); const content = screen.getByTestId('standalone'); @@ -9,7 +9,7 @@ test('is possible to render a standalone component', async () => { expect(content).toHaveTextContent('Standalone Component'); }); -test('is possibl to render a standalone component with a child', async () => { +test('can render a standalone component with a child', async () => { await render(StandaloneWithChildComponent, { componentProperties: { name: 'Bob' }, }); 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 e203991d..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 { } +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 new file mode 100644 index 00000000..4a88a580 --- /dev/null +++ b/apps/example-app/src/app/examples/20-test-harness.spec.ts @@ -0,0 +1,35 @@ +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatSnackBarHarness } from '@angular/material/snack-bar/testing'; +import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; + +import { HarnessComponent } from './20-test-harness'; + +test.skip('can be used with TestHarness', async () => { + const view = await render(``, { + imports: [HarnessComponent], + }); + const loader = TestbedHarnessEnvironment.documentRootLoader(view.fixture); + + const buttonHarness = await loader.getHarness(MatButtonHarness); + const button = await buttonHarness.host(); + button.click(); + + const snackbarHarness = await loader.getHarness(MatSnackBarHarness); + expect(await snackbarHarness.getMessage()).toMatch(/Pizza Party!!!/i); +}); + +test.skip('can be used in combination with TestHarness', async () => { + const user = userEvent.setup(); + + const view = await render(HarnessComponent); + const loader = TestbedHarnessEnvironment.documentRootLoader(view.fixture); + + await user.click(screen.getByRole('button')); + + const snackbarHarness = await loader.getHarness(MatSnackBarHarness); + expect(await snackbarHarness.getMessage()).toMatch(/Pizza Party!!!/i); + + expect(screen.getByText(/Pizza Party!!!/i)).toBeInTheDocument(); +}); diff --git a/apps/example-app/src/app/examples/20-test-harness.ts b/apps/example-app/src/app/examples/20-test-harness.ts new file mode 100644 index 00000000..0ecb7b35 --- /dev/null +++ b/apps/example-app/src/app/examples/20-test-harness.ts @@ -0,0 +1,19 @@ +import { Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + +@Component({ + selector: 'atl-harness', + standalone: true, + imports: [MatButtonModule, MatSnackBarModule], + template: ` + + `, +}) +export class HarnessComponent { + 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 new file mode 100644 index 00000000..7b66d85a --- /dev/null +++ b/apps/example-app/src/app/examples/21-deferable-view.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'atl-deferable-view-child', + template: `

    Hello from deferred child component

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

    Hello from placeholder

    + } @loading { +

    Hello from loading

    + } @error { +

    Hello from error

    + } + `, + imports: [DeferableViewChildComponent], + standalone: true, +}) +export class DeferableViewComponent {} diff --git a/apps/example-app/src/app/examples/21-deferable-view.spec.ts b/apps/example-app/src/app/examples/21-deferable-view.spec.ts new file mode 100644 index 00000000..84953876 --- /dev/null +++ b/apps/example-app/src/app/examples/21-deferable-view.spec.ts @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/angular'; +import { DeferBlockState } from '@angular/core/testing'; +import { DeferableViewComponent } from './21-deferable-view.component'; + +test('renders deferred views based on state', async () => { + const { renderDeferBlock } = await render(DeferableViewComponent); + + expect(screen.getByText(/Hello from placeholder/i)).toBeInTheDocument(); + + await renderDeferBlock(DeferBlockState.Loading); + expect(screen.getByText(/Hello from loading/i)).toBeInTheDocument(); + + await renderDeferBlock(DeferBlockState.Complete); + expect(screen.getByText(/Hello from deferred child component/i)).toBeInTheDocument(); +}); + +test('initially renders deferred views based on given state', async () => { + await render(DeferableViewComponent, { + deferBlockStates: DeferBlockState.Error, + }); + + expect(screen.getByText(/Hello from error/i)).toBeInTheDocument(); +}); 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 new file mode 100644 index 00000000..355e8ae4 --- /dev/null +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.spec.ts @@ -0,0 +1,128 @@ +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, { + inputs: { + ...aliasedInput('greeting', 'Hello'), + name: 'world', + age: '45', + }, + }); + + const inputValue = within(screen.getByTestId('input-value')); + expect(inputValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); +}); + +test('works with computed', async () => { + await render(SignalInputComponent, { + inputs: { + ...aliasedInput('greeting', 'Hello'), + name: 'world', + age: '45', + }, + }); + + const computedValue = within(screen.getByTestId('computed-value')); + expect(computedValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); +}); + +test('can update signal inputs', async () => { + const { fixture } = await render(SignalInputComponent, { + 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 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 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'); +}); + +test('output emits a value', async () => { + const submitFn = jest.fn(); + await render(SignalInputComponent, { + inputs: { + ...aliasedInput('greeting', 'Hello'), + name: 'world', + age: '45', + }, + on: { + submitValue: submitFn, + }, + }); + + await userEvent.click(screen.getByRole('button')); + + expect(submitFn).toHaveBeenCalledWith('world'); +}); + +test('model update also updates the template', async () => { + const { fixture } = await render(SignalInputComponent, { + inputs: { + ...aliasedInput('greeting', 'Hello'), + name: 'initial', + age: '45', + }, + }); + + const inputValue = within(screen.getByTestId('input-value')); + const computedValue = within(screen.getByTestId('computed-value')); + + expect(inputValue.getByText(/hello initial/i)).toBeInTheDocument(); + expect(computedValue.getByText(/hello initial/i)).toBeInTheDocument(); + + await userEvent.clear(screen.getByRole('textbox')); + await userEvent.type(screen.getByRole('textbox'), 'updated'); + + expect(inputValue.getByText(/hello updated/i)).toBeInTheDocument(); + expect(computedValue.getByText(/hello updated/i)).toBeInTheDocument(); + expect(fixture.componentInstance.name()).toBe('updated'); + + fixture.componentInstance.name.set('new value'); + // set doesn't trigger change detection within the test, findBy is needed to update the template + expect(await inputValue.findByText(/hello new value/i)).toBeInTheDocument(); + expect(await computedValue.findByText(/hello new value/i)).toBeInTheDocument(); + + // it's not recommended to access the model directly, but it's possible + expect(fixture.componentInstance.name()).toBe('new value'); +}); + +test('works with signal inputs, computed values, and rerenders', async () => { + const view = await render(SignalInputComponent, { + 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 of 45 years old/i)).toBeInTheDocument(); + expect(computedValue.getByText(/hello world of 45 years old/i)).toBeInTheDocument(); + + await view.rerender({ + inputs: { + ...aliasedInput('greeting', 'bye'), + name: 'test', + age: '0', + }, + }); + + expect(inputValue.getByText(/bye test 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 new file mode 100644 index 00000000..27ed23b7 --- /dev/null +++ b/apps/example-app/src/app/examples/22-signal-inputs.component.ts @@ -0,0 +1,28 @@ +import { Component, computed, input, model, numberAttribute, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'atl-signal-input', + template: ` +
    {{ greetings() }} {{ name() }} of {{ age() }} years old
    +
    {{ greetingMessage() }}
    + + + `, + standalone: true, + imports: [FormsModule], +}) +export class SignalInputComponent { + greetings = input('', { + alias: 'greeting', + }); + age = input.required({ transform: numberAttribute }); + name = model.required(); + submitValue = output(); + + greetingMessage = computed(() => `${this.greetings()} ${this.name()} of ${this.age()} years old`); + + submitName() { + 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/app/issues/issue-106.spec.ts b/apps/example-app/src/app/issues/issue-106.spec.ts deleted file mode 100644 index 56097a83..00000000 --- a/apps/example-app/src/app/issues/issue-106.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Component } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; -import { render, screen, fireEvent } from '@testing-library/angular'; -import { waitFor } from '@testing-library/dom'; - -@Component({ - template: ` -
    Here I am
    `, -}) -class TestSelectComponent { - showSubj = new BehaviorSubject(false); - show$ = this.showSubj.asObservable(); - - toggleShow() { - this.showSubj.next(true); - } -} - -test('https://github.com/testing-library/angular-testing-library/issues/106', async () => { - await render(TestSelectComponent); - const toggle = screen.getByTestId('toggle'); - const hiddenText = screen.queryByTestId('getme'); - - expect(hiddenText).not.toBeInTheDocument(); - fireEvent.click(toggle); - - // fails - // await waitFor(() => expect(hiddenText).not.toBeNull()); - - // succeeds - /// Next line is disabled, because we wish to test the behavior of the library and test the bug/issue #106 - /// @see https://github.com/testing-library/angular-testing-library/pull/277/files#r779743116 - // eslint-disable-next-line testing-library/prefer-presence-queries, testing-library/prefer-find-by - await waitFor(() => expect(screen.queryByTestId('getme')).toBeInTheDocument()); -}); - -test('better https://github.com/testing-library/angular-testing-library/issues/106', async () => { - await render(TestSelectComponent); - const toggle = screen.getByTestId('toggle'); - const hiddenText = screen.queryByTestId('getme'); - - expect(hiddenText).not.toBeInTheDocument(); - fireEvent.click(toggle); - - expect(screen.getByTestId('getme')).toBeInTheDocument(); -}); diff --git a/apps/example-app/src/app/issues/issue-254.spec.ts b/apps/example-app/src/app/issues/issue-254.spec.ts deleted file mode 100644 index 917f35de..00000000 --- a/apps/example-app/src/app/issues/issue-254.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { render, screen } from '@testing-library/angular'; -import { createMock } from '@testing-library/angular/jest-utils'; - -interface Division { - jobType?: string; - jobBullets?: string[]; - description?: string; -} - -@Inject({ - providedIn: 'root', -}) -class JobsService { - divisions(): Promise { - throw new Error('Method not implemented.'); - } -} - -@Component({ - selector: 'app-home-career-oportunities', - template: `
      -
    • - {{ bullet }} -
    • -
    `, -}) -class CareerOportunitiesComponent implements OnInit { - dedicated: Division | undefined; - intermodal: Division | undefined; - noCdl: Division | undefined; - otr: Division | undefined; - - constructor(private jobsService: JobsService) {} - - ngOnInit(): void { - this.jobsService.divisions().then((apiDivisions) => { - this.dedicated = apiDivisions.find((c) => c.jobType === 'DEDICATED'); - this.intermodal = apiDivisions.find((c) => c.jobType === 'INTERMODAL'); - this.noCdl = apiDivisions.find((c) => c.jobType === 'NO_CDL'); - this.otr = apiDivisions.find((c) => c.jobType === 'OVER_THE_ROAD'); - }); - } -} - -test('Render Component', async () => { - const divisions2: Division[] = [ - { - jobType: 'INTERMODAL', - jobBullets: ['Local Routes', 'Flexible Schedules', 'Competitive Pay'], - description: '', - }, - { jobType: 'NO_CDL', jobBullets: ['We Train', 'We Hire', 'We Pay'], description: '' }, - { - jobType: 'OVER_THE_ROAD', - jobBullets: ['Great Miles', 'Competitive Pay', 'Explore the Country'], - description: '', - }, - { - jobType: 'DEDICATED', - jobBullets: ['Regular Routes', 'Consistent Miles', 'Great Pay'], - description: '', - }, - ]; - const jobService = createMock(JobsService); - jobService.divisions = jest.fn(() => Promise.resolve(divisions2)); - - await render(CareerOportunitiesComponent, { - componentProviders: [ - { - provide: JobsService, - useValue: jobService, - }, - ], - }); - const listItemControl = await screen.findAllByRole('listitem'); - expect(listItemControl).toHaveLength(3); -}); diff --git a/apps/example-app/src/app/material.module.ts b/apps/example-app/src/app/material.module.ts deleted file mode 100644 index fb7970b9..00000000 --- a/apps/example-app/src/app/material.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; - -@NgModule({ - exports: [MatInputModule, MatSelectModule], -}) -export class MaterialModule {} diff --git a/apps/example-app/src/assets/.gitkeep b/apps/example-app/src/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/example-app/src/environments/environment.prod.ts b/apps/example-app/src/environments/environment.prod.ts deleted file mode 100644 index c9669790..00000000 --- a/apps/example-app/src/environments/environment.prod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const environment = { - production: true, -}; diff --git a/apps/example-app/src/environments/environment.ts b/apps/example-app/src/environments/environment.ts deleted file mode 100644 index 85db3caf..00000000 --- a/apps/example-app/src/environments/environment.ts +++ /dev/null @@ -1,15 +0,0 @@ -// This file can be replaced during build by using the `fileReplacements` array. -// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. -// The list of file replacements can be found in `angular.json`. - -export const environment = { - production: false, -}; - -/* - * In development mode, to ignore zone related error stack frames such as - * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can - * import the following file, but please comment it out in production mode - * because it will have performance impact when throw error - */ -// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/apps/example-app/src/favicon.ico b/apps/example-app/src/favicon.ico deleted file mode 100644 index 8081c7ce..00000000 Binary files a/apps/example-app/src/favicon.ico and /dev/null differ diff --git a/apps/example-app/src/index.html b/apps/example-app/src/index.html deleted file mode 100644 index 930133fd..00000000 --- a/apps/example-app/src/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - AngularTestingLibraryApp - - - - - - - - - diff --git a/apps/example-app/src/main.ts b/apps/example-app/src/main.ts deleted file mode 100644 index 741c9eb8..00000000 --- a/apps/example-app/src/main.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; - -if (environment.production) { - enableProdMode(); -} - -platformBrowserDynamic() - .bootstrapModule(AppModule) - .catch((err) => console.log(err)); diff --git a/apps/example-app/src/polyfills.ts b/apps/example-app/src/polyfills.ts deleted file mode 100644 index f84fd8a6..00000000 --- a/apps/example-app/src/polyfills.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * This file includes polyfills needed by Angular and is loaded before the app. - * You can add your own extra polyfills to this file. - * - * This file is divided into 2 sections: - * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. - * 2. Application imports. Files imported after ZoneJS that should be loaded before your main - * file. - * - * The current setup is for so-called "evergreen" browsers; the last versions of browsers that - * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), - * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. - * - * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html - */ - -/** ************************************************************************************************* - * BROWSER POLYFILLS - */ - -/** IE9, IE10 and IE11 requires all of the following polyfills. **/ -// import 'core-js/es6/symbol'; -// import 'core-js/es6/object'; -// import 'core-js/es6/function'; -// import 'core-js/es6/parse-int'; -// import 'core-js/es6/parse-float'; -// import 'core-js/es6/number'; -// import 'core-js/es6/math'; -// import 'core-js/es6/string'; -// import 'core-js/es6/date'; -// import 'core-js/es6/array'; -// import 'core-js/es6/regexp'; -// import 'core-js/es6/map'; -// import 'core-js/es6/weak-map'; -// import 'core-js/es6/set'; - -/** IE10 and IE11 requires the following for the Reflect API. */ -// import 'core-js/es6/reflect'; - -/** Evergreen browsers require these. **/ -// Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. - -/** - * By default, zone.js will patch all possible macroTask and DomEvents - * user can disable parts of macroTask/DomEvents patch by setting following flags - */ - -// (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame -// (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick -// (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames - -/* - * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js - * with the following flag, it will bypass `zone.js` patch for IE/Edge - */ -// (window as any).__Zone_enable_cross_context_check = true; - -/** ************************************************************************************************* - * Zone JS is required by default for Angular itself. - */ -import 'zone.js'; // Included with Angular CLI. - -/** ************************************************************************************************* - * APPLICATION IMPORTS - */ diff --git a/apps/example-app/src/styles.css b/apps/example-app/src/styles.css deleted file mode 100644 index dd3d5ed2..00000000 --- a/apps/example-app/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -@import '@angular/material/prebuilt-themes/deeppurple-amber.css'; diff --git a/apps/example-app/src/test-setup.ts b/apps/example-app/src/test-setup.ts index 8301387c..96bfd347 100644 --- a/apps/example-app/src/test-setup.ts +++ b/apps/example-app/src/test-setup.ts @@ -1,8 +1,4 @@ -import 'jest-preset-angular/setup-jest'; +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; import '@testing-library/jest-dom'; -import { configure } from '@testing-library/angular'; -import { ReactiveFormsModule } from '@angular/forms'; -configure({ - defaultImports: [ReactiveFormsModule], -}); +setupZoneTestEnv(); diff --git a/apps/example-app/tsconfig.app.json b/apps/example-app/tsconfig.app.json index a009fad9..b0e22e14 100644 --- a/apps/example-app/tsconfig.app.json +++ b/apps/example-app/tsconfig.app.json @@ -4,9 +4,10 @@ "outDir": "../../dist/out-tsc", "types": [], "allowJs": true, - "target": "ES2017" + "target": "ES2022", + "useDefineForClassFields": false }, - "files": ["src/main.ts", "src/polyfills.ts"], + "files": ["src/main.ts"], "include": ["src/**/*.d.ts"], - "exclude": ["**/*.test.ts", "**/*.spec.ts"] + "exclude": ["**/*.test.ts", "**/*.spec.ts", "jest.config.ts"] } diff --git a/apps/example-app/tsconfig.json b/apps/example-app/tsconfig.json index 523f7fc7..c0e57dc9 100644 --- a/apps/example-app/tsconfig.json +++ b/apps/example-app/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.base.json", "files": [], "include": [], - "compilerOptions": {}, + "compilerOptions": { + "target": "es2020" + }, "angularCompilerOptions": { "strictInjectionParameters": true, "strictInputAccessModifiers": true, diff --git a/apps/example-app/tsconfig.spec.json b/apps/example-app/tsconfig.spec.json index d8a4c20f..83f36dfd 100644 --- a/apps/example-app/tsconfig.spec.json +++ b/apps/example-app/tsconfig.spec.json @@ -6,5 +6,5 @@ "types": ["jest", "node", "@testing-library/jest-dom"] }, "files": ["src/test-setup.ts"], - "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] } diff --git a/decorate-angular-cli.js b/decorate-angular-cli.js deleted file mode 100644 index bc81a837..00000000 --- a/decorate-angular-cli.js +++ /dev/null @@ -1,69 +0,0 @@ -/** - * This file decorates the Angular CLI with the Nx CLI to enable features such as computation caching - * and faster execution of tasks. - * - * It does this by: - * - * - Patching the Angular CLI to warn you in case you accidentally use the undecorated ng command. - * - Symlinking the ng to nx command, so all commands run through the Nx CLI - * - Updating the package.json postinstall script to give you control over this script - * - * The Nx CLI decorates the Angular CLI, so the Nx CLI is fully compatible with it. - * Every command you run should work the same when using the Nx CLI, except faster. - * - * Because of symlinking you can still type `ng build/test/lint` in the terminal. The ng command, in this case, - * will point to nx, which will perform optimizations before invoking ng. So the Angular CLI is always invoked. - * The Nx CLI simply does some optimizations before invoking the Angular CLI. - * - * To opt out of this patch: - * - Replace occurrences of nx with ng in your package.json - * - Remove the script from your postinstall script in your package.json - * - Delete and reinstall your node_modules - */ - -const fs = require('fs'); -const os = require('os'); -const cp = require('child_process'); -const isWindows = os.platform() === 'win32'; -let output; -try { - output = require('@nrwl/workspace').output; -} catch (e) { - console.warn('Angular CLI could not be decorated to enable computation caching. Please ensure @nrwl/workspace is installed.'); - process.exit(0); -} - -/** - * Symlink of ng to nx, so you can keep using `ng build/test/lint` and still - * invoke the Nx CLI and get the benefits of computation caching. - */ -function symlinkNgCLItoNxCLI() { - try { - const ngPath = './node_modules/.bin/ng'; - const nxPath = './node_modules/.bin/nx'; - if (isWindows) { - /** - * This is the most reliable way to create symlink-like behavior on Windows. - * Such that it works in all shells and works with npx. - */ - ['', '.cmd', '.ps1'].forEach(ext => { - if (fs.existsSync(nxPath + ext)) fs.writeFileSync(ngPath + ext, fs.readFileSync(nxPath + ext)); - }); - } else { - // If unix-based, symlink - cp.execSync(`ln -sf ./nx ${ngPath}`); - } - } - catch(e) { - output.error({ title: 'Unable to create a symlink from the Angular CLI to the Nx CLI:' + e.message }); - throw e; - } -} - -try { - symlinkNgCLItoNxCLI(); - require('@nrwl/cli/lib/decorate-cli').decorateCli(); - output.log({ title: 'Angular CLI has been decorated to enable computation caching.' }); -} catch(e) { - output.error({ title: 'Decoration of the Angular CLI did not complete successfully' }); -} 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.js b/jest.config.js deleted file mode 100644 index 42c86fd0..00000000 --- a/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -const { getJestProjects } = require('@nrwl/jest'); - -module.exports = { - projects: getJestProjects(), -}; diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 00000000..f5c10f47 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,5 @@ +const { getJestProjectsAsync } = require('@nx/jest'); + +export default async () => ({ + projects: await getJestProjectsAsync(), +}); diff --git a/jest.preset.js b/jest.preset.js index c9b13e14..e0cb70c9 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -1,22 +1,34 @@ -const nxPreset = require('@nrwl/jest/preset'); +const nxPreset = require('@nx/jest/preset').default; + module.exports = { ...nxPreset, testMatch: ['**/+(*.)+(spec|test).+(ts|js)?(x)'], transform: { - '^.+\\.(ts|mjs|js|html)$': 'jest-preset-angular', + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], }, transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], - resolver: '@nrwl/jest/plugins/resolver', + resolver: '@nx/jest/plugins/resolver', moduleFileExtensions: ['ts', 'js', 'html'], - globals: { - 'ts-jest': { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.(html|svg)$', - }, - }, + globals: {}, snapshotSerializers: [ 'jest-preset-angular/build/serializers/no-ng-attributes', 'jest-preset-angular/build/serializers/ng-snapshot', 'jest-preset-angular/build/serializers/html-comment', ], + /* TODO: Update to latest Jest snapshotFormat + * By default Nx has kept the older style of Jest Snapshot formats + * to prevent breaking of any existing tests with snapshots. + * It's recommend you update to the latest format. + * You can do this by removing snapshotFormat property + * and running tests with --update-snapshot flag. + * Example: "nx affected --targets=test --update-snapshot" + * More info: https://jestjs.io/docs/upgrading-to-jest29#snapshot-format + */ + snapshotFormat: { escapeString: true, printBasicPrototype: true }, }; diff --git a/nx.json b/nx.json index d8e87c08..a308e678 100644 --- a/nx.json +++ b/nx.json @@ -1,8 +1,4 @@ { - "npmScope": "testing-library", - "affected": { - "defaultBase": "main" - }, "workspaceLayout": { "appsDir": "apps", "libsDir": "projects" @@ -15,33 +11,14 @@ "environment": "all" } }, - "implicitDependencies": { - "package.json": { - "dependencies": "*", - "devDependencies": "*" - }, - ".eslintrc.json": "*" - }, "tasksRunnerOptions": { "default": { - "runner": "@nrwl/nx-cloud", "options": { - "accessToken": "M2Q4YjlkNjMtMzY1NC00ZjkwLTk1ZjgtZjg5Y2VkMzFjM2FifHJlYWQtd3JpdGU=", - "cacheableOperations": ["build", "test", "lint", "e2e"], "canTrackAnalytics": false, - "showUsageWarnings": true, - "parallel": 3 + "showUsageWarnings": true } } }, - "targetDependencies": { - "build": [ - { - "target": "build", - "projects": "dependencies" - } - ] - }, "generators": { "@nrlw/workspace:library": { "linter": "eslint", @@ -50,7 +27,7 @@ "standaloneConfig": true, "buildable": true }, - "@nrwl/angular:application": { + "@nx/angular:application": { "style": "scss", "linter": "eslint", "unitTestRunner": "jest", @@ -59,15 +36,14 @@ "standaloneConfig": true, "tags": ["type:app"] }, - "@nrwl/angular:library": { - "style": "scss", + "@nx/angular:library": { "linter": "eslint", "unitTestRunner": "jest", "strict": true, "standaloneConfig": true, "publishable": true }, - "@nrwl/angular:component": { + "@nx/angular:component": { "style": "scss", "displayBlock": true, "changeDetection": "OnPush" @@ -80,5 +56,52 @@ } } }, - "defaultProject": "example-app" + "defaultProject": "example-app", + "$schema": "./node_modules/nx/schemas/nx-schema.json", + "targetDefaults": { + "build": { + "dependsOn": ["^build"], + "inputs": ["production", "^production"], + "cache": true + }, + "test": { + "inputs": ["default", "^production"], + "cache": true + }, + "@nx/jest:jest": { + "inputs": ["default", "^production"], + "cache": true, + "options": { + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "@nx/eslint:lint": { + "inputs": ["default", "{workspaceRoot}/eslint.config.cjs"], + "cache": true + } + }, + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "sharedGlobals": [], + "production": [ + "default", + "!{projectRoot}/**/*.spec.[jt]s", + "!{projectRoot}/tsconfig.spec.json", + "!{projectRoot}/karma.conf.js", + "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", + "!{projectRoot}/jest.config.[jt]s", + "!{projectRoot}/eslint.config.cjs", + "!{projectRoot}/src/test-setup.[jt]s" + ] + }, + "nxCloudAccessToken": "M2Q4YjlkNjMtMzY1NC00ZjkwLTk1ZjgtZjg5Y2VkMzFjM2FifHJlYWQtd3JpdGU=", + "parallel": 3, + "useInferencePlugins": false, + "defaultBase": "main" } diff --git a/other/hedgehog.png b/other/hedgehog.png deleted file mode 100644 index ce0a940c..00000000 Binary files a/other/hedgehog.png and /dev/null differ diff --git a/other/logo-icon.svg b/other/logo-icon.svg new file mode 100644 index 00000000..48d2878c --- /dev/null +++ b/other/logo-icon.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/other/logo-transparent.svg b/other/logo-transparent.svg new file mode 100644 index 00000000..795caf7c --- /dev/null +++ b/other/logo-transparent.svg @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/other/logo.jpg b/other/logo.jpg new file mode 100644 index 00000000..4d627712 Binary files /dev/null and b/other/logo.jpg differ diff --git a/other/logo.png b/other/logo.png new file mode 100644 index 00000000..599227aa Binary files /dev/null and b/other/logo.png differ diff --git a/other/logo.svg b/other/logo.svg new file mode 100644 index 00000000..d0f25fe6 --- /dev/null +++ b/other/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 818f0dd6..341eb0f5 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,12 @@ "scripts": { "ng": "nx", "nx": "nx", - "postinstall": "node ./decorate-angular-cli.js && ngcc --properties es2015 browser module main", "start": "nx serve", "prebuild": "rimraf dist", - "build": "nx run-many --target=build --projects=testing-library,jest-utils", + "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", - "lint": "nx workspace-lint && nx lint", + "test": "nx run-many --target=test --all --parallel=1", + "lint": "nx run-many --all --target=lint", "e2e": "nx e2e", "affected:apps": "nx affected:apps", "affected:libs": "nx affected:libs", @@ -28,75 +27,83 @@ "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "14.0.0", - "@angular/cdk": "14.0.0", - "@angular/common": "14.0.0", - "@angular/compiler": "14.0.0", - "@angular/core": "14.0.0", - "@angular/material": "14.0.0", - "@angular/platform-browser": "14.0.0", - "@angular/platform-browser-dynamic": "14.0.0", - "@angular/router": "14.0.0", - "@ngrx/store": "14.0.0-beta.0", - "@nrwl/angular": "13.4.3", - "@nrwl/nx-cloud": "13.0.2", - "@testing-library/dom": "^8.11.1", - "rxjs": "^7.5.1", - "tslib": "~2.3.1", - "zone.js": "~0.11.4" + "@angular/animations": "20.1.7", + "@angular/cdk": "20.1.6", + "@angular/common": "20.1.7", + "@angular/compiler": "20.1.7", + "@angular/core": "20.1.7", + "@angular/material": "20.1.6", + "@angular/platform-browser": "20.1.7", + "@angular/platform-browser-dynamic": "20.1.7", + "@angular/router": "20.1.7", + "@ngrx/store": "20.0.0", + "@nx/angular": "21.3.11", + "@testing-library/dom": "^10.4.0", + "rxjs": "7.8.0", + "tslib": "~2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "14.0.0", - "@angular-eslint/eslint-plugin": "13.0.1", - "@angular-eslint/eslint-plugin-template": "13.0.1", - "@angular-eslint/template-parser": "13.0.1", - "@angular/cli": "14.0.0", - "@angular/compiler-cli": "14.0.0", - "@angular/forms": "14.0.0", - "@angular/language-service": "14.0.0", - "@nrwl/cli": "13.4.3", - "@nrwl/eslint-plugin-nx": "13.4.3", - "@nrwl/jest": "13.4.3", - "@nrwl/linter": "13.4.3", - "@nrwl/node": "13.4.3", - "@nrwl/nx-plugin": "13.4.3", - "@nrwl/workspace": "13.4.3", - "@testing-library/jasmine-dom": "^1.2.0", - "@testing-library/jest-dom": "^5.15.1", - "@testing-library/user-event": "^13.5.0", - "@types/jasmine": "^3.10.2", - "@types/jest": "27.4.0", - "@types/node": "14.14.37", - "@typescript-eslint/eslint-plugin": "~5.3.0", - "@typescript-eslint/parser": "~5.3.0", - "cpy-cli": "^3.1.1", - "eslint": "~8.6.0", - "eslint-config-prettier": "8.3.0", - "eslint-plugin-import": "~2.25.4", - "eslint-plugin-jasmine": "~4.1.3", - "eslint-plugin-jest": "~25.3.4", - "eslint-plugin-jest-dom": "~4.0.1", - "eslint-plugin-testing-library": "~5.0.1", - "jasmine-core": "^3.10.1", - "jasmine-spec-reporter": "^7.0.0", - "jest": "28.1.1", - "jest-environment-jsdom": "^28.1.1", - "jest-preset-angular": "12.1.0", - "karma": "^6.3.9", - "karma-chrome-launcher": "^3.1.0", - "karma-jasmine": "^4.0.1", - "karma-jasmine-html-reporter": "^1.7.0", - "lint-staged": "^12.1.6", - "ng-packagr": "14.0.0", - "postcss": "^8.4.5", - "postcss-import": "^14.0.2", - "postcss-preset-env": "^7.2.0", - "postcss-url": "^10.1.3", - "prettier": "^2.4.1", - "rimraf": "^3.0.2", - "semantic-release": "^18.0.0", - "ts-jest": "28.0.4", - "ts-node": "~10.4.0", - "typescript": "4.7.2" + "@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": "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": "30.0.5", + "jest-environment-jsdom": "30.0.5", + "jest-preset-angular": "15.0.0", + "karma": "6.4.0", + "karma-chrome-launcher": "^3.2.0", + "karma-coverage": "^2.2.1", + "karma-jasmine": "5.1.0", + "karma-jasmine-html-reporter": "2.0.0", + "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": "^5.0.10", + "semantic-release": "^24.2.1", + "ts-jest": "29.4.1", + "ts-node": "10.9.1", + "typescript": "5.8.2", + "typescript-eslint": "^8.19.0", + "jest-util": "30.0.5" } } diff --git a/projects/jest-utils/.eslintrc.json b/projects/jest-utils/.eslintrc.json deleted file mode 100644 index 785bfebc..00000000 --- a/projects/jest-utils/.eslintrc.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "extends": "../../.eslintrc.json", - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts"], - "extends": ["plugin:@nrwl/nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], - "parserOptions": { - "project": ["projects/jest-utils/tsconfig.*?.json"] - }, - "rules": { - "@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:@nrwl/nx/angular-template"], - "rules": {} - } - ] -} diff --git a/projects/jest-utils/jest.config.js b/projects/jest-utils/jest.config.js deleted file mode 100644 index 0d80b628..00000000 --- a/projects/jest-utils/jest.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - name: 'Jest utils', - displayName: { - name: 'JEST UTILS', - color: 'magenta', - }, - preset: '../../jest.preset.js', - setupFilesAfterEnv: ['/test-setup.ts'], -}; diff --git a/projects/jest-utils/ng-package.json b/projects/jest-utils/ng-package.json deleted file mode 100644 index 210c7bc0..00000000 --- a/projects/jest-utils/ng-package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", - "dest": "../../dist/@testing-library/angular/jest-utils", - "deleteDestPath": false, - "lib": { - "entryFile": "index.ts" - } -} diff --git a/projects/jest-utils/package.json b/projects/jest-utils/package.json deleted file mode 100644 index 4462dc31..00000000 --- a/projects/jest-utils/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@testing-library/angular/jest-utils", - "version": "0.0.0-semantically-released", - "description": "Test your Angular components with the dom-testing-library", - "repository": { - "type": "git", - "url": "git+https://github.com/testing-library/angular-testing-library.git" - }, - "keywords": [ - "angular", - "ngx", - "ng", - "typescript", - "angular2", - "test", - "dom-testing-library" - ], - "author": "Tim Deschryver", - "license": "MIT", - "bugs": { - "url": "https://github.com/testing-library/angular-testing-library/issues" - }, - "homepage": "https://github.com/testing-library/angular-testing-library#readme", - "peerDependencies": { - "jest": ">=27.0.0" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "tslib": "^2.0.0" - } -} diff --git a/projects/jest-utils/project.json b/projects/jest-utils/project.json deleted file mode 100644 index 8c4976f6..00000000 --- a/projects/jest-utils/project.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "projectType": "library", - "root": "projects/jest-utils", - "sourceRoot": "projects/jest-utils/src", - "prefix": "lib", - "targets": { - "build-package": { - "executor": "@nrwl/angular:package", - "outputs": ["dist/@testing-library/angular/jest-utils"], - "options": { - "project": "projects/jest-utils/ng-package.json" - }, - "configurations": { - "production": { - "tsConfig": "projects/jest-utils/tsconfig.lib.prod.json" - }, - "development": { - "tsConfig": "projects/jest-utils/tsconfig.lib.json" - } - }, - "defaultConfiguration": "production" - }, - "lint": { - "executor": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": [ - "projects/jest-utils/**/*.ts", - "projects/jest-utils/**/*.html", - "projects/jest-utils/src/**/*.html" - ] - }, - "outputs": ["{options.outputFile}"] - }, - "build": { - "executor": "@nrwl/workspace:run-commands", - "options": { - "parallel": false, - "commands": [ - { - "command": "nx run jest-utils:build-package" - } - ] - } - }, - "test": { - "executor": "@nrwl/jest:jest", - "options": { - "jestConfig": "projects/jest-utils/jest.config.js" - }, - "outputs": ["coverage/projects/jest-utils"] - } - }, - "tags": [] -} diff --git a/projects/jest-utils/test-setup.ts b/projects/jest-utils/test-setup.ts deleted file mode 100644 index 0da94a0a..00000000 --- a/projects/jest-utils/test-setup.ts +++ /dev/null @@ -1,2 +0,0 @@ -import 'jest-preset-angular/setup-jest'; -import '@testing-library/jest-dom'; diff --git a/projects/jest-utils/tsconfig.json b/projects/jest-utils/tsconfig.json deleted file mode 100644 index 24663f6f..00000000 --- a/projects/jest-utils/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - }, - { - "path": "./tsconfig.lib.prod.json" - }, - { - "path": "./tsconfig.spec.json" - } - ], - "compilerOptions": {}, - "angularCompilerOptions": { - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true, - "flatModuleId": "AUTOGENERATED", - "flatModuleOutFile": "AUTOGENERATED" - } -} diff --git a/projects/jest-utils/tsconfig.lib.json b/projects/jest-utils/tsconfig.lib.json deleted file mode 100644 index 0566ff84..00000000 --- a/projects/jest-utils/tsconfig.lib.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "declaration": true, - "declarationMap": true, - "inlineSources": true, - "types": ["jest"] - }, - "exclude": ["src/test.ts", "**/*.spec.ts", "**/*.test.ts"], - "include": ["**/*.ts"] -} diff --git a/projects/jest-utils/tsconfig.lib.prod.json b/projects/jest-utils/tsconfig.lib.prod.json deleted file mode 100644 index 2a2faa88..00000000 --- a/projects/jest-utils/tsconfig.lib.prod.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.lib.json", - "compilerOptions": { - "declarationMap": false - }, - "angularCompilerOptions": { - "compilationMode": "partial" - } -} diff --git a/projects/jest-utils/tsconfig.spec.json b/projects/jest-utils/tsconfig.spec.json deleted file mode 100644 index 73461f35..00000000 --- a/projects/jest-utils/tsconfig.spec.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "types": ["node", "jest", "@testing-library/jest-dom"] - }, - "files": ["test-setup.ts"], - "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] -} diff --git a/projects/testing-library/.eslintrc.json b/projects/testing-library/.eslintrc.json deleted file mode 100644 index 918e7859..00000000 --- a/projects/testing-library/.eslintrc.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "extends": "../../.eslintrc.json", - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts"], - "rules": { - "@typescript-eslint/ban-ts-comment": "off" - } - }, - { - "files": ["*.ts"], - "extends": ["plugin:@nrwl/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:@nrwl/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/jest-utils/index.ts b/projects/testing-library/jest-utils/index.ts similarity index 100% rename from projects/jest-utils/index.ts rename to projects/testing-library/jest-utils/index.ts diff --git a/projects/testing-library/jest-utils/ng-package.json b/projects/testing-library/jest-utils/ng-package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/projects/testing-library/jest-utils/ng-package.json @@ -0,0 +1 @@ +{} diff --git a/projects/jest-utils/src/lib/create-mock.ts b/projects/testing-library/jest-utils/src/lib/create-mock.ts similarity index 100% rename from projects/jest-utils/src/lib/create-mock.ts rename to projects/testing-library/jest-utils/src/lib/create-mock.ts diff --git a/projects/jest-utils/src/lib/index.ts b/projects/testing-library/jest-utils/src/lib/index.ts similarity index 100% rename from projects/jest-utils/src/lib/index.ts rename to projects/testing-library/jest-utils/src/lib/index.ts diff --git a/projects/jest-utils/src/public_api.ts b/projects/testing-library/jest-utils/src/public_api.ts similarity index 100% rename from projects/jest-utils/src/public_api.ts rename to projects/testing-library/jest-utils/src/public_api.ts diff --git a/projects/jest-utils/tests/create-mock.spec.ts b/projects/testing-library/jest-utils/tests/create-mock.spec.ts similarity index 95% rename from projects/jest-utils/tests/create-mock.spec.ts rename to projects/testing-library/jest-utils/tests/create-mock.spec.ts index 2393fe30..c20109b6 100644 --- a/projects/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.js b/projects/testing-library/jest.config.ts similarity index 73% rename from projects/testing-library/jest.config.js rename to projects/testing-library/jest.config.ts index 216a1f4e..bc5a665d 100644 --- a/projects/testing-library/jest.config.js +++ b/projects/testing-library/jest.config.ts @@ -1,5 +1,4 @@ -module.exports = { - name: 'Angular Testing Library', +export default { displayName: { name: 'ATL', color: 'magenta', diff --git a/projects/testing-library/package.json b/projects/testing-library/package.json index 59ac5d28..6ea1a38c 100644 --- a/projects/testing-library/package.json +++ b/projects/testing-library/package.json @@ -26,17 +26,16 @@ "save": "devDependencies" }, "ng-update": { - "migrations": "./schematics/migrations/migration.json" + "migrations": "./schematics/migrations/migrations.json" }, "peerDependencies": { - "@angular/common": ">= 14.0.0", - "@angular/platform-browser": ">= 14.0.0", - "@angular/router": ">= 14.0.0", - "@angular/core": ">= 14.0.0", - "rxjs": ">= 7.4.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": { - "@testing-library/dom": "^8.0.0", "tslib": "^2.3.1" }, "publishConfig": { diff --git a/projects/testing-library/project.json b/projects/testing-library/project.json index 1caa86ee..1deb065a 100644 --- a/projects/testing-library/project.json +++ b/projects/testing-library/project.json @@ -1,12 +1,14 @@ { + "name": "testing-library", + "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "library", - "root": "projects/testing-library", "sourceRoot": "projects/testing-library/src", "prefix": "lib", + "tags": [], "targets": { "build-package": { - "executor": "@nrwl/angular:package", - "outputs": ["dist/@testing-library/angular"], + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/@testing-library/angular"], "options": { "project": "projects/testing-library/ng-package.json" }, @@ -21,14 +23,10 @@ "defaultConfiguration": "production" }, "lint": { - "executor": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": ["projects/testing-library/**/*.ts", "projects/testing-library/**/*.html"] - }, - "outputs": ["{options.outputFile}"] + "executor": "@nx/eslint:lint" }, "build": { - "executor": "@nrwl/workspace:run-commands", + "executor": "nx:run-commands", "options": { "parallel": false, "commands": [ @@ -45,12 +43,12 @@ } }, "test": { - "executor": "@nrwl/jest:jest", + "executor": "@nx/jest:jest", "options": { - "jestConfig": "projects/testing-library/jest.config.js" + "jestConfig": "projects/testing-library/jest.config.ts", + "passWithNoTests": false }, - "outputs": ["coverage/projects/testing-library"] + "outputs": ["{workspaceRoot}/coverage/projects/testing-library"] } - }, - "tags": [] + } } 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 new file mode 100644 index 00000000..ebc3922a --- /dev/null +++ b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.spec.ts @@ -0,0 +1,42 @@ +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { EmptyTree } from '@angular-devkit/schematics'; + +test('adds DTL to devDependencies', async () => { + const tree = await setup({}); + const pkg = tree.readContent('package.json'); + + expect(pkg).toMatchInlineSnapshot(` + "{ + \\"devDependencies\\": { + \\"@testing-library/dom\\": \\"^10.0.0\\" + } + }" + `); +}); + +test('ignores if DTL is already listed as a dev dependency', async () => { + const tree = await setup({ devDependencies: { '@testing-library/dom': '^9.0.0' } }); + const pkg = tree.readContent('package.json'); + + expect(pkg).toMatchInlineSnapshot(`"{\\"devDependencies\\":{\\"@testing-library/dom\\":\\"^9.0.0\\"}}"`); +}); + +test('ignores if DTL is already listed as a dependency', async () => { + const tree = await setup({ dependencies: { '@testing-library/dom': '^11.0.0' } }); + const pkg = tree.readContent('package.json'); + + expect(pkg).toMatchInlineSnapshot(`"{\\"dependencies\\":{\\"@testing-library/dom\\":\\"^11.0.0\\"}}"`); +}); + +async function setup(packageJson: object) { + const collectionPath = path.join(__dirname, '../migrations.json'); + const schematicRunner = new SchematicTestRunner('schematics', collectionPath); + + const tree = new UnitTestTree(new EmptyTree()); + tree.create('package.json', JSON.stringify(packageJson)); + + await schematicRunner.runSchematic(`atl-add-dtl-as-dev-dependency`, {}, tree); + + return tree; +} diff --git a/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.ts b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.ts new file mode 100644 index 00000000..1c06e2f6 --- /dev/null +++ b/projects/testing-library/schematics/migrations/dtl-as-dev-dependency/index.ts @@ -0,0 +1,20 @@ +import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; +import { + addPackageJsonDependency, + getPackageJsonDependency, + NodeDependencyType, +} from '@schematics/angular/utility/dependencies'; + +const dtl = '@testing-library/dom'; + +export default function (): Rule { + return async (tree: Tree, context: SchematicContext) => { + const dtlDep = getPackageJsonDependency(tree, dtl); + if (dtlDep) { + context.logger.info(`Skipping installation of '@testing-library/dom' because it's already installed.`); + } else { + context.logger.info(`Adding '@testing-library/dom' as a peer dependency.`); + addPackageJsonDependency(tree, { name: dtl, type: NodeDependencyType.Dev, overwrite: false, version: '^10.0.0' }); + } + }; +} diff --git a/projects/testing-library/schematics/migrations/migration.json b/projects/testing-library/schematics/migrations/migration.json deleted file mode 100644 index 63001b44..00000000 --- a/projects/testing-library/schematics/migrations/migration.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "schematics": {} -} diff --git a/projects/testing-library/schematics/migrations/migrations.json b/projects/testing-library/schematics/migrations/migrations.json new file mode 100644 index 00000000..711b7ae0 --- /dev/null +++ b/projects/testing-library/schematics/migrations/migrations.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "atl-add-dtl-as-dev-dependency": { + "description": "Add @testing-library/dom as a dev dependency", + "version": "17.0.0-beta.3", + "factory": "./dtl-as-dev-dependency/index" + } + } +} diff --git a/projects/testing-library/schematics/ng-add/index.ts b/projects/testing-library/schematics/ng-add/index.ts index 68b9dfa2..868d2031 100644 --- a/projects/testing-library/schematics/ng-add/index.ts +++ b/projects/testing-library/schematics/ng-add/index.ts @@ -1,11 +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'; + +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(), + ]); + }; +} + +function addDependency(packageName: string, version: string, dependencyType: NodeDependencyType) { + return (tree: Tree, context: SchematicContext) => { + const dtlDep = getPackageJsonDependency(tree, packageName); + if (dtlDep) { + context.logger.info(`Skipping installation of '${packageName}' because it's already installed.`); + } else { + 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()); -export default function (): Rule { - return (host: Tree, context: SchematicContext) => { 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 host; }; } 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 7799c3ce..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 } 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 { /** @@ -55,31 +81,74 @@ export interface RenderResult extend /** * @description * Re-render the same component with different properties. - * This creates a new instance of the component. + * Properties not passed in again are removed. */ - rerender: (rerenderedProperties: Partial) => void; - + rerender: ( + properties?: Pick< + RenderTemplateOptions, + 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' + > & { partialUpdate?: boolean }, + ) => Promise; /** * @description - * Keeps the current fixture intact and invokes ngOnChanges with the updated properties. + * Set the state of a deferrable block. */ - change: (changedProperties: Partial) => void; + 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 - * Will call detectChanges when the component is compiled + * Automatically detect changes as a "real" running component would do. * * @default * true * * @example - * const component = await render(AppComponent, { - * detectChanges: false + * await render(AppComponent, { + * autoDetectChanges: false * }) */ - detectChanges?: boolean; + autoDetectChanges?: boolean; + /** + * @description + * Invokes `detectChanges` after the component is rendered + * + * @default + * true + * + * @example + * await render(AppComponent, { + * detectChangesOnRender: false + * }) + */ + detectChangesOnRender?: boolean; + /** * @description * A collection of components, directives and pipes needed to render the component, for example, nested components of the component. @@ -90,11 +159,11 @@ export interface RenderComponentOptions | unknown[])[]; /** * @description * A collection of providers needed to render the component via Dependency Injection, for example, injectable services or tokens. @@ -105,7 +174,7 @@ export interface RenderComponentOptions | ModuleWithProviders)[]; /** * @description * A collection of schemas needed to render the component. @@ -146,7 +214,7 @@ export interface RenderComponentOptions { ... } @@ -169,6 +237,99 @@ export interface RenderComponentOptions; + /** + * @description + * An object to set `@Input` properties of the component + * + * @deprecated use the `inputs` option instead. When you need to use aliases, use the `aliasedInput(...)` helper function. + * @default + * {} + * + * @example + * await render(AppComponent, { + * componentInputs: { + * counterValue: 10 + * } + * }) + */ + componentInputs?: Partial | Record; + + /** + * @description + * An object to set `@Input` or `input()` properties of the component + * + * @default + * {} + * + * @example + * 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: { + * emit: sendValue + * } + * } + * }) + */ + componentOutputs?: Partial; + + /** + * @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. @@ -179,13 +340,47 @@ export interface RenderComponentOptions[]; + /** + * @description + * A collection of imports to override a standalone component's imports with. + * + * @default + * undefined + * + * @example + * await render(AppComponent, { + * componentImports: [ + * MockChildComponent + * ] + * }) + */ + componentImports?: (Type | unknown[])[]; /** * @description * Queries to bind. Overrides the default set from DOM Testing Library unless merged. @@ -197,7 +392,7 @@ export interface RenderComponentOptions {} + * + * @example + * await render(AppComponent, { + * configureTestBed: (testBed) => { } + * }) + */ + configureTestBed?: (testbed: TestBed) => void; + + /** + * @description + * Set the initial state of a deferrable block. + */ + deferBlockStates?: DeferBlockState | { deferBlockState: DeferBlockState; deferBlockIndex: number }[]; + + /** + * @description + * Set the defer blocks behavior. + */ + deferBlockBehavior?: DeferBlockBehavior; +} + +export interface ComponentOverride { + component: Type; + 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 * * @example - * const component = await render(`
    `, { + * await render(`
    `, { * declarations: [SpoilerDirective] * wrapper: CustomWrapperComponent * }) @@ -286,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 07fdf71d..a8bc1ea3 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -1,36 +1,46 @@ import { + ApplicationInitStatus, ChangeDetectorRef, Component, - Type, NgZone, - SimpleChange, OnChanges, + OutputRef, + OutputRefSubscription, + Provider, + SimpleChange, SimpleChanges, - ApplicationInitStatus, - ษตisStandalone, + Type, + isStandalone, + Binding, } from '@angular/core'; -import { ComponentFixture, TestBed, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing'; import { NavigationExtras, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; +import type { BoundFunctions, Queries } from '@testing-library/dom'; import { + configure as dtlConfigure, getQueriesForElement as dtlGetQueriesForElement, prettyDOM as dtlPrettyDOM, + queries as dtlQueries, + screen as dtlScreen, waitFor as dtlWaitFor, waitForElementToBeRemoved as dtlWaitForElementToBeRemoved, - screen as dtlScreen, - within as dtlWithin, waitForOptions as dtlWaitForOptions, - configure as dtlConfigure, - queries as dtlQueries, + within as dtlWithin, } from '@testing-library/dom'; -import type { Queries, BoundFunctions } from '@testing-library/dom'; -import { RenderComponentOptions, RenderTemplateOptions, RenderResult } 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 inject = TestBed.inject || TestBed.get; export async function render( component: Type, @@ -47,7 +57,8 @@ export async function render( ): Promise> { const { dom: domConfig, ...globalConfig } = getConfig(); const { - detectChanges: detectChangesOnRender = true, + detectChangesOnRender = true, + autoDetectChanges = true, declarations = [], imports = [], providers = [], @@ -55,17 +66,34 @@ export async function render( queries, wrapper = WrapperComponent as Type, componentProperties = {}, + componentInputs = {}, + componentOutputs = {}, + inputs: newInputs = {}, + on = {}, + bindings = [], componentProviders = [], + childComponentOverrides = [], + componentImports, excludeComponentDeclaration = false, routes = [], removeAngularAttributes = false, defaultImports = [], - } = { ...globalConfig, ...renderOptions }; + initialRoute = '', + deferBlockStates = undefined, + deferBlockBehavior = undefined, + configureTestBed = () => { + /* noop*/ + }, + } = { ...globalConfig, ...renderOptions } as RenderComponentOptions & + RenderTemplateOptions & + Config; dtlConfigure({ eventWrapper: (cb) => { const result = cb(); - detectChangesForMountedFixtures(); + if (autoDetectChanges) { + detectChangesForMountedFixtures(); + } return result; }, ...domConfig, @@ -77,75 +105,52 @@ export async function render( excludeComponentDeclaration, wrapper, }), - imports: addAutoImports(sut,{ + imports: addAutoImports(sut, { imports: imports.concat(defaultImports), routes, }), - providers: [...providers], + providers, schemas: [...schemas], + deferBlockBehavior: deferBlockBehavior ?? DeferBlockBehavior.Manual, }); + overrideComponentImports(sut, componentImports); + overrideChildComponentProviders(childComponentOverrides); + + configureTestBed(TestBed); 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); - let fixture: ComponentFixture; - let detectChanges: () => void; - - await renderFixture(componentProperties); - - const rerender = async (rerenderedProperties: Partial) => { - await renderFixture(rerenderedProperties); - }; - - const change = (changedProperties: Partial) => { - const changes = getChangesObj(fixture.componentInstance, changedProperties); - - setComponentProperties(fixture, { componentProperties: changedProperties }); - - if (hasOnChangesHook(fixture.componentInstance)) { - fixture.componentInstance.ngOnChanges(changes); - } - - fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges(); - }; - - const zone = inject(NgZone); - - const router = inject(Router); - if (typeof router?.initialNavigation === 'function') { - router?.initialNavigation(); - } - - const navigate = async (elementOrPath: Element | string, basePath = ''): Promise => { + 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('?'); const queryParams = params ? params.split('&').reduce((qp, q) => { - const [key, value] = q.split('='); - const currentValue = qp[key]; - if (typeof currentValue === 'undefined') { - qp[key] = value; - } else if (Array.isArray(currentValue)) { - qp[key] = [...currentValue, value]; - } else { - qp[key] = [currentValue, value]; - } - return qp; - }, {} as Record) + const [key, value] = q.split('='); + const currentValue = qp[key]; + if (typeof currentValue === 'undefined') { + qp[key] = value; + } else if (Array.isArray(currentValue)) { + qp[key] = [...currentValue, value]; + } else { + qp[key] = [currentValue, value]; + } + return qp; + }, {} as Record) : undefined; const navigateOptions: NavigationExtras | undefined = queryParams ? { - queryParams, - } + queryParams, + } : undefined; const doNavigate = () => { @@ -155,74 +160,221 @@ export async function render( let result; if (zone) { - await zone.run(() => (result = doNavigate())); + await zone.run(() => { + result = doNavigate(); + }); } else { result = doNavigate(); } - - detectChanges(); return result ?? false; }; - return { - // @ts-ignore: fixture assigned - fixture, - detectChanges: () => detectChanges(), - navigate, - rerender, - change, - // @ts-ignore: fixture assigned - debugElement: typeof sut === 'string' ? fixture.debugElement : fixture.debugElement.query(By.directive(sut)), - // @ts-ignore: fixture assigned - 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)), - // @ts-ignore: fixture assigned - ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), - }; + if (initialRoute) await _navigate(initialRoute); - async function renderFixture(properties: Partial) { - if (fixture) { - cleanupAtFixture(fixture); + if (typeof router?.initialNavigation === 'function') { + if (zone) { + zone.run(() => router.initialNavigation()); + } else { + router.initialNavigation(); } + } + + let detectChanges: () => void; + + 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.', + ); + } - fixture = await createComponent(componentContainer); - setComponentProperties(fixture, { componentProperties: properties }); + // 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) { - fixture.nativeElement.removeAttribute('ng-version'); - const idAttribute = fixture.nativeElement.getAttribute('id'); - if (idAttribute && idAttribute.startsWith('root')) { - fixture.nativeElement.removeAttribute('id'); + createdFixture.nativeElement.removeAttribute('ng-version'); + const idAttribute = createdFixture.nativeElement.getAttribute('id'); + if (idAttribute?.startsWith('root')) { + createdFixture.nativeElement.removeAttribute('id'); } } - mountedFixtures.add(fixture); + + mountedFixtures.add(createdFixture); let isAlive = true; - fixture.componentRef.onDestroy(() => (isAlive = false)); + createdFixture.componentRef.onDestroy(() => { + isAlive = false; + }); - if (hasOnChangesHook(fixture.componentInstance)) { + if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length > 0) { const changes = getChangesObj(null, componentProperties); - fixture.componentInstance.ngOnChanges(changes); + createdFixture.componentInstance.ngOnChanges(changes); } detectChanges = () => { if (isAlive) { - fixture.detectChanges(); + createdFixture.detectChanges(); } }; if (detectChangesOnRender) { detectChanges(); } + + return createdFixture; + }; + + const fixture = await renderFixture(componentProperties, allInputs as any, componentOutputs, on); + + if (deferBlockStates) { + if (Array.isArray(deferBlockStates)) { + for (const deferBlockState of deferBlockStates) { + await renderDeferBlock(fixture, deferBlockState.deferBlockState, deferBlockState.deferBlockIndex); + } + } else { + await renderDeferBlock(fixture, deferBlockStates); + } } + + const rerender = async ( + properties?: Pick< + RenderTemplateOptions, + 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' + > & { partialUpdate?: boolean }, + ) => { + const newComponentInputs = { ...properties?.componentInputs, ...properties?.inputs }; + const changesInComponentInput = update( + fixture, + renderedInputKeys, + newComponentInputs, + setComponentInputs, + properties?.partialUpdate ?? false, + ); + renderedInputKeys = Object.keys(newComponentInputs); + + const newComponentOutputs = properties?.componentOutputs ?? {}; + for (const outputKey of renderedOutputKeys) { + if (!Object.prototype.hasOwnProperty.call(newComponentOutputs, outputKey)) { + delete (fixture.componentInstance as any)[outputKey]; + } + } + 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, + renderedPropKeys, + newComponentProps, + setComponentProperties, + properties?.partialUpdate ?? false, + ); + renderedPropKeys = Object.keys(newComponentProps); + + if (hasOnChangesHook(fixture.componentInstance)) { + fixture.componentInstance.ngOnChanges({ + ...changesInComponentInput, + ...changesInComponentProps, + }); + } + + if (properties?.detectChangesOnRender !== false) { + fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges(); + } + }; + + const navigate = async (elementOrPath: Element | string, basePath = ''): Promise => { + const result = await _navigate(elementOrPath, basePath); + detectChanges(); + return result; + }; + + return { + fixture, + detectChanges: () => detectChanges(), + navigate, + rerender, + renderDeferBlock: async (deferBlockState: DeferBlockState, deferBlockIndex?: number) => { + await renderDeferBlock(fixture, deferBlockState, deferBlockIndex); + }, + debugElement: fixture.debugElement, + container: fixture.nativeElement, + 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 createComponent(component: Type): Promise> { +async function createComponent( + component: Type, + bindings?: Binding[], +): Promise> { /* Make sure angular application is initialized before creating component */ - await inject(ApplicationInitStatus).donePromise; + await TestBed.inject(ApplicationInitStatus).donePromise; + + // Use the new bindings API if available and bindings are provided + if (bindings && bindings.length > 0) { + return TestBed.createComponent(component, { bindings }); + } + return TestBed.createComponent(component); } @@ -239,7 +391,7 @@ function createComponentFixture( function setComponentProperties( fixture: ComponentFixture, - { componentProperties = {} }: Pick, 'componentProperties'>, + componentProperties: RenderTemplateOptions['componentProperties'] = {}, ) { for (const key of Object.keys(componentProperties)) { const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key); @@ -265,24 +417,113 @@ function setComponentProperties( return fixture; } +function setComponentOutputs( + fixture: ComponentFixture, + componentOutputs: RenderTemplateOptions['componentOutputs'] = {}, +) { + for (const [name, value] of Object.entries(componentOutputs)) { + (fixture.componentInstance as any)[name] = value; + } +} + +function setComponentInputs( + fixture: ComponentFixture, + componentInputs: RenderTemplateOptions['componentInputs'] = {}, +) { + for (const [name, value] of Object.entries(componentInputs)) { + fixture.componentRef.setInput(name, value); + } +} + +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)) { + TestBed.overrideComponent(sut, { set: { imports } }); + } else { + throw new Error( + `Error while rendering ${sut}: Cannot specify componentImports on a template or non-standalone component.`, + ); + } + } +} + +function overrideChildComponentProviders(componentOverrides: ComponentOverride[]) { + if (componentOverrides) { + for (const { component, providers } of componentOverrides) { + TestBed.overrideComponent(component, { set: { providers: providers as Provider[] } }); + } + } +} + function hasOnChangesHook(componentInstance: SutType): componentInstance is SutType & OnChanges { return ( - 'ngOnChanges' in componentInstance && typeof (componentInstance as SutType & OnChanges).ngOnChanges === 'function' + componentInstance !== null && + typeof componentInstance === 'object' && + 'ngOnChanges' in componentInstance && + typeof (componentInstance as SutType & OnChanges).ngOnChanges === 'function' ); } -function getChangesObj>( - oldProps: Partial | null, - newProps: Partial, -) { +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 SutType, - ); + return Object.keys(newProps).reduce((changes, key) => { + changes[key] = new SimpleChange(isFirstChange ? null : oldProps[key], newProps[key], isFirstChange); + return changes; + }, {} as Record); +} + +function update( + fixture: ComponentFixture, + prevRenderedKeys: string[], + newValues: Record, + updateFunction: ( + fixture: ComponentFixture, + values: RenderTemplateOptions['componentInputs' | 'componentProperties'], + ) => void, + partialUpdate: boolean, +) { + const componentInstance = fixture.componentInstance as Record; + const simpleChanges: SimpleChanges = {}; + + 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]; + } + } + } + + for (const [key, value] of Object.entries(newValues)) { + if (value !== componentInstance[key]) { + simpleChanges[key] = new SimpleChange(componentInstance[key], value, false); + } + } + + updateFunction(fixture, newValues); + + return simpleChanges; } function addAutoDeclarations( @@ -293,27 +534,49 @@ 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()]; + const components = () => (excludeComponentDeclaration || isStandalone(sut) ? [] : [sut]); + 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()]; + const components = () => (typeof sut !== 'string' && isStandalone(sut) ? [sut] : []); + return [...imports, ...components(), ...routing()]; +} + +async function renderDeferBlock( + fixture: ComponentFixture, + deferBlockState: DeferBlockState, + deferBlockIndex?: number, +) { + const deferBlockFixtures = await fixture.getDeferBlocks(); + + if (deferBlockIndex !== undefined) { + if (deferBlockIndex < 0) { + throw new Error('deferBlockIndex must be a positive number'); + } + + const deferBlockFixture = deferBlockFixtures[deferBlockIndex]; + if (!deferBlockFixture) { + throw new Error(`Could not find a deferrable block with index '${deferBlockIndex}'`); + } + await deferBlockFixture.render(deferBlockState); + } else { + for (const deferBlockFixture of deferBlockFixtures) { + await deferBlockFixture.render(deferBlockState); + } + } } /** @@ -327,12 +590,10 @@ async function waitForWrapper( let inFakeAsync = true; try { tick(0); - } catch (err) { + } catch { inFakeAsync = false; } - detectChanges(); - return await dtlWaitFor(() => { setTimeout(() => detectChanges(), 0); if (inFakeAsync) { @@ -381,7 +642,10 @@ function cleanupAtFixture(fixture: ComponentFixture) { if (!fixture.nativeElement.getAttribute('ng-version') && fixture.nativeElement.parentNode === document.body) { document.body.removeChild(fixture.nativeElement); + } else if (!fixture.nativeElement.getAttribute('id') && document.body.children?.[0] === fixture.nativeElement) { + document.body.removeChild(fixture.nativeElement); } + mountedFixtures.delete(fixture); } @@ -397,8 +661,8 @@ if (typeof process === 'undefined' || !process.env?.ATL_SKIP_AUTO_CLEANUP) { } } -@Component({ selector: 'atl-wrapper-component', template: '' }) -class WrapperComponent { } +@Component({ selector: 'atl-wrapper-component', template: '', standalone: false }) +class WrapperComponent {} /** * Wrap findBy queries to poke the Angular change detection cycle @@ -424,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) { @@ -432,7 +696,7 @@ function detectChangesForMountedFixtures() { throw err; } } - }); + } } /** diff --git a/projects/testing-library/test-setup.ts b/projects/testing-library/test-setup.ts index 0da94a0a..be311bfe 100644 --- a/projects/testing-library/test-setup.ts +++ b/projects/testing-library/test-setup.ts @@ -1,2 +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'; + +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/change.spec.ts b/projects/testing-library/tests/change.spec.ts deleted file mode 100644 index 1ba67513..00000000 --- a/projects/testing-library/tests/change.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core'; -import { render, screen } from '../src/public_api'; - -@Component({ - selector: 'atl-fixture', - template: ` {{ firstName }} {{ lastName }} `, -}) -class FixtureComponent { - @Input() firstName = 'Sarah'; - @Input() lastName?: string; -} - -test('changes the component with updated props', async () => { - const { change } = await render(FixtureComponent); - expect(screen.getByText('Sarah')).toBeInTheDocument(); - - const firstName = 'Mark'; - change({ firstName }); - - expect(screen.getByText(firstName)).toBeInTheDocument(); -}); - -test('changes the component with updated props while keeping other props untouched', async () => { - const firstName = 'Mark'; - const lastName = 'Peeters'; - const { change } = await render(FixtureComponent, { - componentProperties: { - firstName, - lastName, - }, - }); - - expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); - - const firstName2 = 'Chris'; - change({ firstName: firstName2 }); - - expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument(); -}); - -@Component({ - selector: 'atl-fixture', - template: ` {{ name }} `, -}) -class FixtureWithNgOnChangesComponent implements OnChanges { - @Input() name = 'Sarah'; - @Input() nameChanged?: (name: string, isFirstChange: boolean) => void; - - ngOnChanges(changes: SimpleChanges) { - if (changes.name && this.nameChanged) { - this.nameChanged(changes.name.currentValue, changes.name.isFirstChange()); - } - } -} - -test('will call ngOnChanges on change', async () => { - const nameChanged = jest.fn(); - const componentProperties = { nameChanged }; - const { change } = await render(FixtureWithNgOnChangesComponent, { componentProperties }); - expect(screen.getByText('Sarah')).toBeInTheDocument(); - - const name = 'Mark'; - change({ name }); - - expect(screen.getByText(name)).toBeInTheDocument(); - expect(nameChanged).toHaveBeenCalledWith(name, false); -}); - -@Component({ - changeDetection: ChangeDetectionStrategy.OnPush, - selector: 'atl-fixture', - template: `
    Number
    `, -}) -class FixtureWithOnPushComponent { - @Input() activeField = ''; -} - -test('update properties on change', async () => { - const { change } = await render(FixtureWithOnPushComponent); - const numberHtmlElementRef = screen.queryByTestId('number'); - - expect(numberHtmlElementRef).not.toHaveClass('active'); - change({ activeField: 'number' }); - expect(numberHtmlElementRef).toHaveClass('active'); -}); 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 new file mode 100644 index 00000000..ffd5e95b --- /dev/null +++ b/projects/testing-library/tests/defer-blocks.spec.ts @@ -0,0 +1,96 @@ +import { Component } from '@angular/core'; +import { DeferBlockBehavior, DeferBlockState } from '@angular/core/testing'; +import { render, screen, fireEvent } from '../src/public_api'; + +test('renders a defer block in different states using the official API', async () => { + const { fixture } = await render(FixtureComponent); + + const deferBlockFixture = (await fixture.getDeferBlocks())[0]; + + await deferBlockFixture.render(DeferBlockState.Loading); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + expect(screen.queryByText(/Defer block content/i)).not.toBeInTheDocument(); + + await deferBlockFixture.render(DeferBlockState.Complete); + expect(screen.getByText(/Defer block content/i)).toBeInTheDocument(); + expect(screen.queryByText(/load/i)).not.toBeInTheDocument(); +}); + +test('renders a defer block in different states using ATL', async () => { + const { renderDeferBlock } = await render(FixtureComponent); + + await renderDeferBlock(DeferBlockState.Loading); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + expect(screen.queryByText(/Defer block content/i)).not.toBeInTheDocument(); + + await renderDeferBlock(DeferBlockState.Complete, 0); + expect(screen.getByText(/Defer block content/i)).toBeInTheDocument(); + expect(screen.queryByText(/load/i)).not.toBeInTheDocument(); +}); + +test('renders a defer block in different states using DeferBlockBehavior.Playthrough', async () => { + await render(FixtureComponent, { + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + expect(await screen.findByText(/Defer block content/i)).toBeInTheDocument(); +}); + +test('renders a defer block in different states using DeferBlockBehavior.Playthrough event', async () => { + await render(FixtureComponentWithEventsComponent, { + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + const button = screen.getByRole('button', { name: /click/i }); + fireEvent.click(button); + + expect(screen.getByText(/empty defer block/i)).toBeInTheDocument(); +}); + +test('renders a defer block initially in the loading state', async () => { + await render(FixtureComponent, { + deferBlockStates: DeferBlockState.Loading, + }); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + expect(screen.queryByText(/Defer block content/i)).not.toBeInTheDocument(); +}); + +test('renders a defer block initially in the complete state', async () => { + await render(FixtureComponent, { + deferBlockStates: DeferBlockState.Complete, + }); + + expect(screen.getByText(/Defer block content/i)).toBeInTheDocument(); + expect(screen.queryByText(/load/i)).not.toBeInTheDocument(); +}); + +test('renders a defer block in an initial state using the array syntax', async () => { + await render(FixtureComponent, { + deferBlockStates: [{ deferBlockState: DeferBlockState.Complete, deferBlockIndex: 0 }], + }); + + expect(screen.getByText(/Defer block content/i)).toBeInTheDocument(); + expect(screen.queryByText(/load/i)).not.toBeInTheDocument(); +}); + +@Component({ + template: ` + @defer { +

Defer block content

+ } @loading { +

Loading...

+ } + `, +}) +class FixtureComponent {} + +@Component({ + template: ` + + @defer(on interaction(trigger)) { +
empty defer block
+ } + `, +}) +class FixtureComponentWithEventsComponent {} diff --git a/projects/testing-library/tests/detect-changes.spec.ts b/projects/testing-library/tests/detect-changes.spec.ts index 766bf31a..363cb402 100644 --- a/projects/testing-library/tests/detect-changes.spec.ts +++ b/projects/testing-library/tests/detect-changes.spec.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { fakeAsync, tick } from '@angular/core/testing'; +import { fakeAsync } from '@angular/core/testing'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { delay } from 'rxjs/operators'; import { render, fireEvent, screen } from '../src/public_api'; @@ -10,6 +10,8 @@ import { render, fireEvent, screen } from '../src/public_api'; `, + standalone: true, + imports: [ReactiveFormsModule], }) class FixtureComponent implements OnInit { inputControl = new FormControl(); @@ -22,7 +24,7 @@ class FixtureComponent implements OnInit { describe('detectChanges', () => { it('does not recognize change if execution is delayed', async () => { - await render(FixtureComponent, { imports: [ReactiveFormsModule] }); + await render(FixtureComponent); fireEvent.input(screen.getByTestId('input'), { target: { @@ -33,9 +35,7 @@ describe('detectChanges', () => { }); it('exposes detectChanges triggering a change detection cycle', fakeAsync(async () => { - const { detectChanges } = await render(FixtureComponent, { - imports: [ReactiveFormsModule], - }); + const { detectChanges } = await render(FixtureComponent); fireEvent.input(screen.getByTestId('input'), { target: { @@ -43,14 +43,17 @@ describe('detectChanges', () => { }, }); - tick(500); + // TODO: The code should be running in the fakeAsync zone to call this function ? + // tick(500); + await new Promise((resolve) => setTimeout(resolve, 500)); + detectChanges(); expect(screen.getByTestId('button').innerHTML).toBe('Button updated after 400ms'); })); it('does not throw on a destroyed fixture', async () => { - const { fixture } = await render(FixtureComponent, { imports: [ReactiveFormsModule] }); + const { fixture } = await render(FixtureComponent); fixture.destroy(); 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/fire-event.spec.ts b/projects/testing-library/tests/fire-event.spec.ts index ebb85017..7b4a90bb 100644 --- a/projects/testing-library/tests/fire-event.spec.ts +++ b/projects/testing-library/tests/fire-event.spec.ts @@ -1,17 +1,47 @@ import { Component } from '@angular/core'; import { render, fireEvent, screen } from '../src/public_api'; +import { FormsModule } from '@angular/forms'; -@Component({ - selector: 'atl-fixture', - template: ` `, -}) -class FixtureComponent {} +describe('fireEvent', () => { + @Component({ + selector: 'atl-fixture', + template: ` +
Hello {{ name }}
`, + standalone: true, + imports: [FormsModule], + }) + class FixtureComponent { + name = ''; + } -test('does not call detect changes when fixture is destroyed', async () => { - const { fixture } = await render(FixtureComponent); + it('automatically detect changes when event is fired', async () => { + await render(FixtureComponent); - fixture.destroy(); + fireEvent.input(screen.getByTestId('input'), { target: { value: 'Tim' } }); - // should otherwise throw - fireEvent.input(screen.getByTestId('input'), { target: { value: 'Bonjour' } }); + expect(screen.getByText('Hello Tim')).toBeInTheDocument(); + }); + + it('can disable automatic detect changes when event is fired', async () => { + const { detectChanges } = await render(FixtureComponent, { + autoDetectChanges: false, + }); + + fireEvent.input(screen.getByTestId('input'), { target: { value: 'Tim' } }); + + expect(screen.queryByText('Hello Tim')).not.toBeInTheDocument(); + + detectChanges(); + + expect(screen.getByText('Hello Tim')).toBeInTheDocument(); + }); + + it('does not call detect changes when fixture is destroyed', async () => { + const { fixture } = await render(FixtureComponent); + + fixture.destroy(); + + // should otherwise throw + fireEvent.input(screen.getByTestId('input'), { target: { value: 'Bonjour' } }); + }); }); diff --git a/projects/testing-library/tests/integration.spec.ts b/projects/testing-library/tests/integration.spec.ts index 112177e1..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 userEvent from '@testing-library/user-event'; 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, @@ -86,11 +91,11 @@ const entities = [ }, ]; -test('renders the table', async () => { +async function setup() { jest.useFakeTimers(); + const user = userEvent.setup(); await render(EntitiesComponent, { - declarations: [TableComponent], providers: [ { provide: EntitiesService, @@ -106,29 +111,50 @@ test('renders the table', async () => { }, ], }); + const modalMock = TestBed.inject(ModalService); + return { + modalMock, + user, + }; +} + +test('renders the heading', async () => { + await setup(); + expect(await screen.findByRole('heading', { name: /Entities Title/i })).toBeInTheDocument(); +}); + +test('renders the entities', async () => { + await setup(); expect(await screen.findByRole('cell', { name: /Entity 1/i })).toBeInTheDocument(); expect(await screen.findByRole('cell', { name: /Entity 2/i })).toBeInTheDocument(); expect(await screen.findByRole('cell', { name: /Entity 3/i })).toBeInTheDocument(); +}); - userEvent.type(await screen.findByRole('textbox', { name: /Search entities/i }), 'Entity 2', {}); +test.skip('finds the cell', async () => { + const { user } = await setup(); + + await user.type(await screen.findByRole('textbox', { name: /Search entities/i }), 'Entity 2', {}); jest.advanceTimersByTime(DEBOUNCE_TIME); await waitForElementToBeRemoved(() => screen.queryByRole('cell', { name: /Entity 1/i })); expect(await screen.findByRole('cell', { name: /Entity 2/i })).toBeInTheDocument(); +}); - userEvent.click(await screen.findByRole('button', { name: /New Entity/i })); +test.skip('opens the modal', async () => { + const { modalMock, user } = await setup(); + await user.click(await screen.findByRole('button', { name: /New Entity/i })); expect(modalMock.open).toHaveBeenCalledWith('new entity'); const row = await screen.findByRole('row', { name: /Entity 2/i, }); - userEvent.click( + await user.click( await within(row).findByRole('button', { name: /edit/i, }), diff --git a/projects/testing-library/tests/integrations/ng-mocks.spec.ts b/projects/testing-library/tests/integrations/ng-mocks.spec.ts new file mode 100644 index 00000000..8886fb3f --- /dev/null +++ b/projects/testing-library/tests/integrations/ng-mocks.spec.ts @@ -0,0 +1,64 @@ +import { Component, ContentChild, EventEmitter, Input, Output, TemplateRef } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { MockComponent } from 'ng-mocks'; +import { render } from '../../src/public_api'; +import { NgIf } from '@angular/common'; + +test('sends the correct value to the child input', async () => { + const utils = await render(TargetComponent, { + imports: [MockComponent(ChildComponent)], + inputs: { value: 'foo' }, + }); + + const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent)); + expect(children).toHaveLength(1); + + const mockComponent = children[0].componentInstance; + expect(mockComponent.someInput).toBe('foo'); +}); + +test('sends the correct value to the child input 2', async () => { + const utils = await render(TargetComponent, { + imports: [MockComponent(ChildComponent)], + inputs: { value: 'bar' }, + }); + + const children = utils.fixture.debugElement.queryAll(By.directive(ChildComponent)); + expect(children).toHaveLength(1); + + const mockComponent = children[0].componentInstance; + expect(mockComponent.someInput).toBe('bar'); +}); + +@Component({ + selector: 'atl-child', + template: 'child', + standalone: true, + imports: [NgIf], +}) +class ChildComponent { + @ContentChild('something') + public injectedSomething: TemplateRef | undefined; + + @Input() + public someInput = ''; + + @Output() + public someOutput = new EventEmitter(); + + public childMockComponent() { + /* noop */ + } +} + +@Component({ + selector: 'atl-target-mock-component', + template: ` `, + standalone: true, + imports: [ChildComponent], +}) +class TargetComponent { + @Input() value = ''; + public trigger = (obj: any) => obj; +} 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 new file mode 100644 index 00000000..1cfe5b85 --- /dev/null +++ b/projects/testing-library/tests/issues/issue-318.spec.ts @@ -0,0 +1,40 @@ +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(); + + ngOnInit(): void { + this.router.events.pipe(takeUntil(this.unsubscribe$)).subscribe((evt) => { + this.eventReceived(evt); + }); + } + + ngOnDestroy(): void { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + + eventReceived(evt: any) { + console.log(evt); + } +} + +test('it does not invoke router events on init', async () => { + const eventReceived = jest.fn(); + await render(FixtureComponent, { + imports: [RouterTestingModule], + componentProperties: { + eventReceived, + }, + }); + expect(eventReceived).not.toHaveBeenCalled(); +}); diff --git a/projects/testing-library/tests/issues/issue-346.spec.ts b/projects/testing-library/tests/issues/issue-346.spec.ts new file mode 100644 index 00000000..ef1b7a38 --- /dev/null +++ b/projects/testing-library/tests/issues/issue-346.spec.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { render } from '../../src/public_api'; + +test('issue 364 detectChangesOnRender', async () => { + @Component({ + selector: 'atl-fixture', + template: `{{ myObj.myProp }}`, + }) + class MyComponent { + myObj: any = null; + } + + // autoDetectChanges invokes change detection, which makes the test fail + await render(MyComponent, { + detectChangesOnRender: false, + }); +}); diff --git a/projects/testing-library/tests/issues/issue-386.spec.ts b/projects/testing-library/tests/issues/issue-386.spec.ts new file mode 100644 index 00000000..b0c5613d --- /dev/null +++ b/projects/testing-library/tests/issues/issue-386.spec.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { throwError } from 'rxjs'; +import { render, screen, fireEvent } from '../../src/public_api'; + +@Component({ + selector: 'atl-fixture', + template: ``, + styles: [], +}) +class TestComponent { + onTest() { + throwError(() => new Error('myerror')).subscribe(); + } +} + +describe('TestComponent', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runAllTicks(); + jest.useRealTimers(); + }); + + it('does not fail', async () => { + await render(TestComponent); + fireEvent.click(screen.getByText('Test')); + }); + + it('fails because of the previous one', async () => { + await render(TestComponent); + fireEvent.click(screen.getByText('Test')); + }); +}); diff --git a/projects/testing-library/tests/issues/issue-389.spec.ts b/projects/testing-library/tests/issues/issue-389.spec.ts new file mode 100644 index 00000000..626d3889 --- /dev/null +++ b/projects/testing-library/tests/issues/issue-389.spec.ts @@ -0,0 +1,15 @@ +import { Component, Input } from '@angular/core'; +import { render, screen } from '../../src/public_api'; + +@Component({ + selector: 'atl-fixture', + template: `Hello {{ name }}`, +}) +class TestComponent { + @Input('aliasName') name = ''; +} + +test('allows you to set componentInputs using the name alias', async () => { + await render(TestComponent, { componentInputs: { aliasName: 'test' } }); + expect(screen.getByText('Hello test')).toBeInTheDocument(); +}); 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 new file mode 100644 index 00000000..7be9913e --- /dev/null +++ b/projects/testing-library/tests/issues/issue-396-standalone-stub-child.spec.ts @@ -0,0 +1,55 @@ +import { Component } from '@angular/core'; +import { render, screen } from '../../src/public_api'; + +test('stub', async () => { + await render(FixtureComponent, { + componentImports: [StubComponent], + }); + + expect(screen.getByText('Hello from stub')).toBeInTheDocument(); +}); + +test('configure', async () => { + await render(FixtureComponent, { + configureTestBed: (testBed) => { + testBed.overrideComponent(FixtureComponent, { + add: { + imports: [StubComponent], + }, + remove: { + imports: [ChildComponent], + }, + }); + }, + }); + + expect(screen.getByText('Hello from stub')).toBeInTheDocument(); +}); + +test('child', async () => { + await render(FixtureComponent); + expect(screen.getByText('Hello from child')).toBeInTheDocument(); +}); + +@Component({ + selector: 'atl-child', + template: `Hello from child`, + standalone: true, +}) +class ChildComponent {} + +@Component({ + selector: 'atl-child', + template: `Hello from stub`, + standalone: true, + host: { 'collision-id': StubComponent.name }, +}) +class StubComponent {} + +@Component({ + selector: 'atl-fixture', + template: ``, + standalone: true, + imports: [ChildComponent], +}) +class FixtureComponent {} 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 new file mode 100644 index 00000000..c34e1304 --- /dev/null +++ b/projects/testing-library/tests/issues/issue-397-directive-overrides-component-input.spec.ts @@ -0,0 +1,68 @@ +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 () => { + await render(``, { + imports: [FixtureComponent, InputOverrideViaConstructorDirective], + }); + + expect(screen.getByText('set by test')).toBeInTheDocument(); +}); + +test('the value set in the directive onInit is used instead of the input binding', async () => { + await render(``, { + imports: [FixtureComponent, InputOverrideViaOnInitDirective], + }); + + expect(screen.getByText('set by directive ngOnInit')).toBeInTheDocument(); +}); + +test('the value set in the directive constructor is used instead of the input value', async () => { + await render(``, { + imports: [FixtureComponent, InputOverrideViaConstructorDirective], + }); + + expect(screen.getByText('set by directive constructor')).toBeInTheDocument(); +}); + +test('the value set in the directive ngOnInit is used instead of the input value and the directive constructor', async () => { + await render(``, { + imports: [FixtureComponent, InputOverrideViaConstructorDirective, InputOverrideViaOnInitDirective], + }); + + expect(screen.getByText('set by directive ngOnInit')).toBeInTheDocument(); +}); + +@Component({ + standalone: true, + selector: 'atl-fixture', + template: `{{ input }}`, +}) +class FixtureComponent { + @Input() public input = 'default value'; +} + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'atl-fixture', + standalone: true, +}) +class InputOverrideViaConstructorDirective { + private readonly fixture = inject(FixtureComponent); + constructor() { + this.fixture.input = 'set by directive constructor'; + } +} + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'atl-fixture', + standalone: true, +}) +class InputOverrideViaOnInitDirective implements OnInit { + 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 new file mode 100644 index 00000000..c775a2ab --- /dev/null +++ b/projects/testing-library/tests/issues/issue-398-component-without-host-id.spec.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import { render, screen } from '../../src/public_api'; + +test('should create the app', async () => { + await render(FixtureComponent); + expect(screen.getByRole('heading')).toBeInTheDocument(); +}); + +test('should re-create the app', async () => { + await render(FixtureComponent); + expect(screen.getByRole('heading')).toBeInTheDocument(); +}); + +@Component({ + selector: 'atl-fixture', + standalone: true, + template: '

My title

', + host: { + '[attr.id]': 'null', // this breaks the cleaning up of tests + }, +}) +class FixtureComponent {} 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 new file mode 100644 index 00000000..6dd5bc0c --- /dev/null +++ b/projects/testing-library/tests/issues/issue-422-view-already-destroyed.spec.ts @@ -0,0 +1,29 @@ +import { Component, ElementRef, inject } from '@angular/core'; +import { NgIf } from '@angular/common'; +import { render } from '../../src/public_api'; + +test('declaration specific dependencies should be available for components', async () => { + @Component({ + selector: 'atl-test', + standalone: true, + template: `
Test
`, + }) + class TestComponent { + // @ts-expect-error - testing purpose + private _el = inject(ElementRef); + } + + await expect(async () => await render(TestComponent)).not.toThrow(); +}); + +test('standalone directives imported in standalone components', async () => { + @Component({ + selector: 'atl-test', + standalone: true, + imports: [NgIf], + template: `
Test
`, + }) + class TestComponent {} + + await render(TestComponent); +}); diff --git a/projects/testing-library/tests/issues/issue-435.spec.ts b/projects/testing-library/tests/issues/issue-435.spec.ts new file mode 100644 index 00000000..2982319b --- /dev/null +++ b/projects/testing-library/tests/issues/issue-435.spec.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { BehaviorSubject } from 'rxjs'; +import { Component, inject, Injectable } from '@angular/core'; +import { screen, render } from '../../src/public_api'; + +// Service +@Injectable() +class DemoService { + buttonTitle = new BehaviorSubject('Click me'); +} + +// Component +@Component({ + selector: 'atl-issue-435', + standalone: true, + imports: [CommonModule], + providers: [DemoService], + template: ` + + `, +}) +class DemoComponent { + protected readonly demoService = inject(DemoService); +} + +test('issue #435', async () => { + await render(DemoComponent); + + const button = screen.getByRole('button', { + name: /Click me/, + }); + + expect(button).toBeVisible(); +}); diff --git a/projects/testing-library/tests/issues/issue-437.spec.ts b/projects/testing-library/tests/issues/issue-437.spec.ts new file mode 100644 index 00000000..dbf2506b --- /dev/null +++ b/projects/testing-library/tests/issues/issue-437.spec.ts @@ -0,0 +1,56 @@ +import userEvent from '@testing-library/user-event'; +import { screen, render } from '../../src/public_api'; +import { MatSidenavModule } from '@angular/material/sidenav'; + +afterEach(() => { + jest.useRealTimers(); +}); + +test('issue #437', async () => { + const user = userEvent.setup(); + await render( + ` + + + + + + +
+
+
+
+ `, + { imports: [MatSidenavModule] }, + ); + + await screen.findByTestId('test-button'); + + await user.click(screen.getByTestId('test-button')); +}); + +test('issue #437 with fakeTimers', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + await render( + ` + + + + + + +
+
+
+
+ `, + { imports: [MatSidenavModule] }, + ); + + 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 9dd20d01..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(); @@ -135,8 +135,8 @@ describe('removeAngularAttributes', () => { }); test('updates properties and invokes change detection', async () => { - const view = await render('
', { - declarations: [UpdateInputDirective], + const view = await render<{ value: string }>('
', { + imports: [UpdateInputDirective], componentProperties: { value: 'value1', }, diff --git a/projects/testing-library/tests/render.spec.ts b/projects/testing-library/tests/render.spec.ts index 8312bd4a..243a5e81 100644 --- a/projects/testing-library/tests/render.spec.ts +++ b/projects/testing-library/tests/render.spec.ts @@ -7,10 +7,21 @@ import { SimpleChanges, APP_INITIALIZER, ApplicationInitStatus, + 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 } from '../src/public_api'; +import { render, fireEvent, screen, OutputRefKeysWithCallback, aliasedInput } from '../src/public_api'; +import { ActivatedRoute, Resolve, RouterModule } from '@angular/router'; +import { fromEvent, map } from 'rxjs'; +import { AsyncPipe, NgIf } from '@angular/common'; @Component({ selector: 'atl-fixture', @@ -21,16 +32,112 @@ import { render, fireEvent } from '../src/public_api'; }) class FixtureComponent {} -test('creates queries and events', async () => { - const view = await render(FixtureComponent); +describe('DTL functionality', () => { + it('creates queries and events', async () => { + const view = await render(FixtureComponent); - /// 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 - expect(view.getByDisplayValue('a super awesome input')).toBeInTheDocument(); - // eslint-disable-next-line testing-library/prefer-screen-queries - fireEvent.click(view.getByText('button')); + // 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 + expect(view.getByDisplayValue('a super awesome input')).toBeInTheDocument(); + // eslint-disable-next-line testing-library/prefer-screen-queries + fireEvent.click(view.getByText('button')); + }); +}); + +describe('components', () => { + @Component({ + selector: 'atl-fixture', + template: ` {{ name }} `, + }) + class FixtureWithInputComponent { + @Input() name = ''; + } + + it('renders component', async () => { + await render(FixtureWithInputComponent, { componentProperties: { name: 'Bob' } }); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); +}); + +describe('component with child', () => { + @Component({ + selector: 'atl-child-fixture', + template: `A child fixture`, + }) + class ChildFixtureComponent {} + + @Component({ + selector: 'atl-child-fixture', + template: `A mock child fixture`, + host: { 'collision-id': MockChildFixtureComponent.name }, + }) + class MockChildFixtureComponent {} + + @Component({ + selector: 'atl-parent-fixture', + template: `

Parent fixture

+
`, + imports: [ChildFixtureComponent], + }) + class ParentFixtureComponent {} + + 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 component with child', async () => { + await render(ParentFixtureComponent); + expect(screen.getByText('Parent fixture')).toBeInTheDocument(); + expect(screen.getByText('A child fixture')).toBeInTheDocument(); + }); + + it('rejects render of template with componentImports set', () => { + const view = render(`
`, { + imports: [ParentFixtureComponent], + componentImports: [MockChildFixtureComponent], + }); + return expect(view).rejects.toMatchObject({ message: /Error while rendering/ }); + }); +}); + +describe('childComponentOverrides', () => { + @Injectable() + class MySimpleService { + public value = 'real'; + } + + @Component({ + selector: 'atl-child-fixture', + template: `{{ simpleService.value }}`, + providers: [MySimpleService], + }) + class NestedChildFixtureComponent { + protected simpleService = inject(MySimpleService); + } + + @Component({ + selector: 'atl-parent-fixture', + template: ``, + imports: [NestedChildFixtureComponent], + }) + class ParentFixtureComponent {} + + it('renders with overridden child service when specified', async () => { + await render(ParentFixtureComponent, { + childComponentOverrides: [ + { + component: NestedChildFixtureComponent, + providers: [{ provide: MySimpleService, useValue: { value: 'fake' } }], + }, + ], + }); + + expect(screen.getByText('fake')).toBeInTheDocument(); + }); }); describe('removeAngularAttributes', () => { @@ -53,35 +160,173 @@ describe('removeAngularAttributes', () => { }); }); -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('componentOutputs', () => { + it('should set passed event emitter to the component', async () => { + @Component({ template: `` }) + class TestFixtureComponent { + @Output() event = new EventEmitter(); + emitEvent() { + this.event.emit(); + } + } + + const mockEmitter = new EventEmitter(); + const spy = jest.spyOn(mockEmitter, 'emit'); + const { fixture } = await render(TestFixtureComponent, { + componentOutputs: { event: mockEmitter }, }); + + fixture.componentInstance.emitEvent(); + + expect(spy).toHaveBeenCalled(); + }); +}); + +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('adds NoopAnimationsModule by default', async () => { - await render(FixtureComponent); - const noopAnimationsModule = TestBed.inject(NoopAnimationsModule); - expect(noopAnimationsModule).toBeDefined(); + 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('does not add NoopAnimationsModule if BrowserAnimationsModule is an import', async () => { - await render(FixtureComponent, { - imports: [BrowserAnimationsModule], + it('should not unsubscribe when same listener function is used on rerender', async () => { + const spy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: spy }, + }); + + await rerender({ on: { event: spy } }); + + fixture.componentInstance.event.emit(); + expect(spy).toHaveBeenCalled(); + }); + + it('should unsubscribe old and subscribe new listener function on rerender', async () => { + const firstSpy = jest.fn(); + const { fixture, rerender } = await render(TestFixtureWithEventEmitterComponent, { + on: { event: firstSpy }, + }); + + const newSpy = jest.fn(); + await rerender({ on: { event: newSpy } }); + + fixture.componentInstance.event.emit(); + + expect(firstSpy).not.toHaveBeenCalled(); + expect(newSpy).toHaveBeenCalled(); + }); + + it('should subscribe passed listener to a derived component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithDerivedEventComponent, { + on: { event: spy }, + }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); + + it('should subscribe passed listener to a functional component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalOutputComponent, { + on: { event: spy }, + }); + fixture.componentInstance.event.emit('test'); + expect(spy).toHaveBeenCalledWith('test'); + }); + + it('should subscribe passed listener to a functional derived component output', async () => { + const spy = jest.fn(); + const { fixture } = await render(TestFixtureWithFunctionalDerivedEventComponent, { + on: { event: spy }, }); + fireEvent.click(fixture.nativeElement); + expect(spy).toHaveBeenCalled(); + }); + + it('OutputRefKeysWithCallback is correctly typed', () => { + const fnWithVoidArg = (_: void) => void 0; + const fnWithNumberArg = (_: number) => void 0; + const fnWithStringArg = (_: string) => void 0; + const fnWithMouseEventArg = (_: MouseEvent) => void 0; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + function _test(_on: OutputRefKeysWithCallback) {} + + // @ts-expect-error 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 {} - const browserAnimationsModule = TestBed.inject(BrowserAnimationsModule); - expect(browserAnimationsModule).toBeDefined(); + @NgModule({ + declarations: [NotStandaloneFixtureComponent], + }) + class FixtureModule {} - expect(() => TestBed.inject(NoopAnimationsModule)).toThrow(); + it('does not throw if component is declared in an imported module', async () => { + await render(NotStandaloneFixtureComponent, { + imports: [FixtureModule], + excludeComponentDeclaration: true, + }); }); }); @@ -102,13 +347,13 @@ describe('Angular component life-cycle hooks', () => { } ngOnChanges(changes: SimpleChanges) { - if (changes.name && this.nameChanged) { - this.nameChanged(changes.name.currentValue, changes.name.isFirstChange()); + if (this.nameChanged) { + this.nameChanged(changes.name?.currentValue, changes.name?.isFirstChange()); } } } - it('will call ngOnInit on initial render', async () => { + it('invokes ngOnInit on initial render', async () => { const nameInitialized = jest.fn(); const componentProperties = { nameInitialized }; const view = await render(FixtureWithNgOnChangesComponent, { componentProperties }); @@ -119,35 +364,270 @@ describe('Angular component life-cycle hooks', () => { expect(nameInitialized).toHaveBeenCalledWith('Initial'); }); - it('will call ngOnChanges on initial render before ngOnInit', async () => { + it('invokes ngOnChanges with componentProperties on initial render before ngOnInit', async () => { const nameInitialized = jest.fn(); const nameChanged = jest.fn(); const componentProperties = { nameInitialized, nameChanged, name: 'Sarah' }; 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.mock.invocationCallOrder[0]).toBeLessThan(nameInitialized.mock.invocationCallOrder[0]); + expect(nameChanged).toHaveBeenCalledTimes(1); + }); + + it('invokes ngOnChanges with componentInputs on initial render before ngOnInit', async () => { + const nameInitialized = jest.fn(); + const nameChanged = jest.fn(); + const componentInput = { nameInitialized, nameChanged, name: 'Sarah' }; + + const view = await render(FixtureWithNgOnChangesComponent, { componentInputs: componentInput }); + + // 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); + }); + + it('does not invoke ngOnChanges when no properties are provided', async () => { + @Component({ template: `` }) + class TestFixtureComponent implements OnChanges { + ngOnChanges() { + throw new Error('should not be called'); + } + } + + const { fixture, detectChanges } = await render(TestFixtureComponent); + const spy = jest.spyOn(fixture.componentInstance, 'ngOnChanges'); + + detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe('initializer', () => { + it('waits for angular app initialization before rendering components', async () => { + const mock = jest.fn(); + + await render(FixtureComponent, { + providers: [ + { + provide: APP_INITIALIZER, + useFactory: () => mock, + multi: true, + }, + ], + }); + + expect(TestBed.inject(ApplicationInitStatus).done).toBe(true); + expect(mock).toHaveBeenCalled(); + }); +}); + +describe('DebugElement', () => { + it('gets the DebugElement', async () => { + const view = await render(FixtureComponent); + + expect(view.debugElement).not.toBeNull(); + expect(view.debugElement.componentInstance).toBeInstanceOf(FixtureComponent); + }); +}); + +describe('initialRoute', () => { + @Component({ + selector: 'atl-fixture2', + template: ``, + }) + class SecondaryFixtureComponent {} + + @Component({ + selector: 'atl-router-fixture', + template: ``, + imports: [RouterModule], + }) + class RouterFixtureComponent {} + + @Injectable() + class FixtureResolver implements Resolve { + public isResolved = false; + + public resolve() { + this.isResolved = true; + } + } + + it('allows initially rendering a specific route to avoid triggering a resolver for the default route', async () => { + const initialRoute = 'initial-route'; + const routes = [ + { path: initialRoute, component: FixtureComponent }, + { path: '**', resolve: { data: FixtureResolver }, component: SecondaryFixtureComponent }, + ]; + + await render(RouterFixtureComponent, { + initialRoute, + routes, + providers: [FixtureResolver], + }); + const resolver = TestBed.inject(FixtureResolver); + + expect(resolver.isResolved).toBe(false); + expect(screen.queryByText('Secondary Component')).not.toBeInTheDocument(); + expect(screen.getByText('button')).toBeInTheDocument(); + }); + + it('allows initially rendering a specific route with query parameters', async () => { + @Component({ + selector: 'atl-query-param-fixture', + template: `

paramPresent$: {{ paramPresent$ | async }}

`, + imports: [NgIf, AsyncPipe], + }) + class QueryParamFixtureComponent { + private readonly route = inject(ActivatedRoute); + + paramPresent$ = this.route.queryParams.pipe(map((queryParams) => (queryParams?.param ? 'present' : 'missing'))); + } + + const initialRoute = 'initial-route?param=query'; + const routes = [{ path: 'initial-route', component: QueryParamFixtureComponent }]; + + await render(RouterFixtureComponent, { + initialRoute, + routes, + }); + + expect(screen.getByText(/present/i)).toBeVisible(); }); }); -test('waits for angular app initialization before rendering components', async () => { - const mock = jest.fn(); +describe('configureTestBed', () => { + it('invokes configureTestBed', async () => { + const configureTestBedFn = jest.fn(); + await render(FixtureComponent, { + configureTestBed: configureTestBedFn, + }); + + 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 - await render(FixtureComponent, { - providers: [ - { - provide: APP_INITIALIZER, - useFactory: () => mock, - multi: true, + 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(TestBed.inject(ApplicationInitStatus).done).toEqual(true); - expect(mock).toHaveBeenCalled(); + 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 0edf69ea..04b8185a 100644 --- a/projects/testing-library/tests/rerender.spec.ts +++ b/projects/testing-library/tests/rerender.spec.ts @@ -1,26 +1,118 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { render, screen } from '../src/public_api'; +let ngOnChangesSpy: jest.Mock; @Component({ selector: 'atl-fixture', template: ` {{ firstName }} {{ lastName }} `, }) -class FixtureComponent { +class FixtureComponent implements OnChanges { @Input() firstName = 'Sarah'; @Input() lastName?: string; + ngOnChanges(changes: SimpleChanges): void { + ngOnChangesSpy(changes); + } } +beforeEach(() => { + ngOnChangesSpy = jest.fn(); +}); + test('rerenders the component with updated props', async () => { const { rerender } = await render(FixtureComponent); expect(screen.getByText('Sarah')).toBeInTheDocument(); const firstName = 'Mark'; - await rerender({ firstName }); + await rerender({ componentProperties: { firstName } }); expect(screen.getByText(firstName)).toBeInTheDocument(); }); -test('rerenders the component with updated props and resets other props', async () => { +test('rerenders without props', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + await rerender(); + + expect(screen.getByText('Sarah')).toBeInTheDocument(); + expect(ngOnChangesSpy).toHaveBeenCalledTimes(1); // one time initially and one time for rerender +}); + +test('rerenders the component with updated inputs', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const firstName = 'Mark'; + await rerender({ inputs: { firstName } }); + + expect(screen.getByText(firstName)).toBeInTheDocument(); +}); + +test('rerenders the component with updated inputs and resets other props', async () => { + const firstName = 'Mark'; + const lastName = 'Peeters'; + const { rerender } = await render(FixtureComponent, { + inputs: { + firstName, + lastName, + }, + }); + + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); + + const firstName2 = 'Chris'; + await rerender({ inputs: { firstName: firstName2 } }); + + expect(screen.getByText(firstName2)).toBeInTheDocument(); + expect(screen.queryByText(firstName)).not.toBeInTheDocument(); + expect(screen.queryByText(lastName)).not.toBeInTheDocument(); + + expect(ngOnChangesSpy).toHaveBeenCalledTimes(2); // one time initially and one time for rerender + const rerenderedChanges = ngOnChangesSpy.mock.calls[1][0] as SimpleChanges; + expect(rerenderedChanges).toEqual({ + lastName: { + previousValue: 'Peeters', + currentValue: undefined, + firstChange: false, + }, + firstName: { + previousValue: 'Mark', + currentValue: 'Chris', + firstChange: false, + }, + }); +}); + +test('rerenders the component with updated inputs and keeps other props when partial is true', async () => { + const firstName = 'Mark'; + const lastName = 'Peeters'; + const { rerender } = await render(FixtureComponent, { + inputs: { + firstName, + lastName, + }, + }); + + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); + + const firstName2 = 'Chris'; + await rerender({ inputs: { firstName: firstName2 }, partialUpdate: true }); + + expect(screen.queryByText(firstName)).not.toBeInTheDocument(); + expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument(); + + expect(ngOnChangesSpy).toHaveBeenCalledTimes(2); // one time initially and one time for rerender + const rerenderedChanges = ngOnChangesSpy.mock.calls[1][0] as SimpleChanges; + expect(rerenderedChanges).toEqual({ + firstName: { + previousValue: 'Mark', + currentValue: 'Chris', + firstChange: false, + }, + }); +}); + +test('rerenders the component with updated props and resets other props with componentProperties', async () => { const firstName = 'Mark'; const lastName = 'Peeters'; const { rerender } = await render(FixtureComponent, { @@ -33,8 +125,64 @@ test('rerenders the component with updated props and resets other props', async expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); const firstName2 = 'Chris'; - rerender({ firstName: firstName2 }); + await rerender({ componentProperties: { firstName: firstName2 } }); - expect(screen.queryByText(`${firstName2} ${lastName}`)).not.toBeInTheDocument(); - expect(screen.queryByText(firstName2)).not.toBeInTheDocument(); + expect(screen.getByText(firstName2)).toBeInTheDocument(); + expect(screen.queryByText(firstName)).not.toBeInTheDocument(); + expect(screen.queryByText(lastName)).not.toBeInTheDocument(); + + expect(ngOnChangesSpy).toHaveBeenCalledTimes(2); // one time initially and one time for rerender + const rerenderedChanges = ngOnChangesSpy.mock.calls[1][0] as SimpleChanges; + expect(rerenderedChanges).toEqual({ + lastName: { + previousValue: 'Peeters', + currentValue: undefined, + firstChange: false, + }, + firstName: { + previousValue: 'Mark', + currentValue: 'Chris', + firstChange: false, + }, + }); +}); + +test('rerenders the component with updated props keeps other props when partial is true', async () => { + const firstName = 'Mark'; + const lastName = 'Peeters'; + const { rerender } = await render(FixtureComponent, { + componentProperties: { + firstName, + lastName, + }, + }); + + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); + + const firstName2 = 'Chris'; + await rerender({ componentProperties: { firstName: firstName2 }, partialUpdate: true }); + + expect(screen.queryByText(firstName)).not.toBeInTheDocument(); + expect(screen.getByText(`${firstName2} ${lastName}`)).toBeInTheDocument(); + + expect(ngOnChangesSpy).toHaveBeenCalledTimes(2); // one time initially and one time for rerender + const rerenderedChanges = ngOnChangesSpy.mock.calls[1][0] as SimpleChanges; + expect(rerenderedChanges).toEqual({ + firstName: { + previousValue: 'Mark', + currentValue: 'Chris', + firstChange: false, + }, + }); +}); + +test('change detection gets not called if `detectChangesOnRender` is set to false', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + const firstName = 'Mark'; + 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 1f2f8eae..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; @@ -16,7 +18,7 @@ class FixtureComponent implements OnInit { test('waits for element to be removed (callback)', async () => { await render(FixtureComponent); - await waitForElementToBeRemoved(() => screen.getByTestId('im-here')); + await waitForElementToBeRemoved(() => screen.queryByTestId('im-here')); expect(screen.queryByTestId('im-here')).not.toBeInTheDocument(); }); @@ -24,7 +26,7 @@ test('waits for element to be removed (callback)', async () => { test('waits for element to be removed (element)', async () => { await render(FixtureComponent); - await waitForElementToBeRemoved(screen.getByTestId('im-here')); + await waitForElementToBeRemoved(screen.queryByTestId('im-here')); expect(screen.queryByTestId('im-here')).not.toBeInTheDocument(); }); @@ -32,7 +34,7 @@ test('waits for element to be removed (element)', async () => { test('allows to override options', async () => { await render(FixtureComponent); - await expect(waitForElementToBeRemoved(() => screen.getByTestId('im-here'), { timeout: 200 })).rejects.toThrow( + await expect(waitForElementToBeRemoved(() => screen.queryByTestId('im-here'), { timeout: 200 })).rejects.toThrow( /Timed out in waitForElementToBeRemoved/i, ); }); diff --git a/projects/testing-library/tests/wait-for.spec.ts b/projects/testing-library/tests/wait-for.spec.ts index e963b0c4..8c6562f0 100644 --- a/projects/testing-library/tests/wait-for.spec.ts +++ b/projects/testing-library/tests/wait-for.spec.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { timer } from 'rxjs'; -import { render, screen, fireEvent, waitFor } from '../src/public_api'; +import { render, screen, waitFor, fireEvent } from '../src/public_api'; @Component({ selector: 'atl-fixture', @@ -24,8 +24,7 @@ test('waits for assertion to become true', async () => { fireEvent.click(screen.getByTestId('button')); - await screen.findByText('Success'); - expect(screen.getByText('Success')).toBeInTheDocument(); + expect(await screen.findByText('Success')).toBeInTheDocument(); }); test('allows to override options', async () => { diff --git a/projects/testing-library/tsconfig.json b/projects/testing-library/tsconfig.json index 24663f6f..21a2b8ef 100644 --- a/projects/testing-library/tsconfig.json +++ b/projects/testing-library/tsconfig.json @@ -13,7 +13,9 @@ "path": "./tsconfig.spec.json" } ], - "compilerOptions": {}, + "compilerOptions": { + "target": "es2020" + }, "angularCompilerOptions": { "strictInjectionParameters": true, "strictInputAccessModifiers": true, diff --git a/projects/testing-library/tsconfig.lib.json b/projects/testing-library/tsconfig.lib.json index 7d77d4c9..0938741e 100644 --- a/projects/testing-library/tsconfig.lib.json +++ b/projects/testing-library/tsconfig.lib.json @@ -5,8 +5,10 @@ "declaration": true, "declarationMap": true, "inlineSources": true, - "types": ["node", "jest"] + "types": ["node", "jest"], + "target": "ES2022", + "useDefineForClassFields": false }, - "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts"], + "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "include": ["**/*.ts"] } diff --git a/projects/testing-library/tsconfig.lib.prod.json b/projects/testing-library/tsconfig.lib.prod.json index 2a2faa88..752ed5ea 100644 --- a/projects/testing-library/tsconfig.lib.prod.json +++ b/projects/testing-library/tsconfig.lib.prod.json @@ -1,9 +1,12 @@ { "extends": "./tsconfig.lib.json", "compilerOptions": { - "declarationMap": false + "declarationMap": false, + "target": "ES2022", + "useDefineForClassFields": false }, "angularCompilerOptions": { "compilationMode": "partial" - } + }, + "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts", "jest.config.ts"] } diff --git a/projects/testing-library/tsconfig.schematics.json b/projects/testing-library/tsconfig.schematics.json index 0573a52a..c0118513 100644 --- a/projects/testing-library/tsconfig.schematics.json +++ b/projects/testing-library/tsconfig.schematics.json @@ -2,16 +2,17 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "strict": true, - "target": "es6", + "target": "ES2020", "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, - "outDir": "../../dist/@testing-library/angular/schematics/ng-add", + "outDir": "../../dist/@testing-library/angular/schematics", "removeComments": true, "skipLibCheck": true, "sourceMap": false }, - "include": ["schematics/**/*.ts"] + "include": ["schematics/**/*.ts"], + "exclude": ["src/test-setup.ts", "**/*.spec.ts", "**/*.test.ts", "jest.config.ts"] } diff --git a/projects/testing-library/tsconfig.spec.json b/projects/testing-library/tsconfig.spec.json index 73461f35..9fee53b3 100644 --- a/projects/testing-library/tsconfig.spec.json +++ b/projects/testing-library/tsconfig.spec.json @@ -5,5 +5,5 @@ "types": ["node", "jest", "@testing-library/jest-dom"] }, "files": ["test-setup.ts"], - "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] } diff --git a/projects/vscode-atl-render/package.json b/projects/vscode-atl-render/package.json index a6462d92..d5bcbe7a 100644 --- a/projects/vscode-atl-render/package.json +++ b/projects/vscode-atl-render/package.json @@ -3,7 +3,7 @@ "displayName": "Angular Testing Library Render Highlighting", "description": "HTML highlighting in ATL the render method", "version": "0.0.3", - "icon": "other/hedgehog.png", + "icon": "other/logo.png", "publisher": "timdeschryver", "license": "MIT", "repository": { diff --git a/tsconfig.base.json b/tsconfig.base.json index 1e0de6e1..b75283e1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,7 +11,7 @@ "moduleResolution": "node", "outDir": "./dist/out-tsc", "sourceMap": true, - "target": "es2015", + "target": "ES2020", "typeRoots": ["node_modules/@types"], "strict": true, "exactOptionalPropertyTypes": true, @@ -23,7 +23,7 @@ "noUnusedParameters": true, "paths": { "@testing-library/angular": ["projects/testing-library"], - "@testing-library/angular/jest-utils": ["projects/jest-utils"] + "@testing-library/angular/jest-utils": ["projects/testing-library/jest-utils"] } }, "exclude": ["node_modules", "tmp"]