diff --git a/.all-contributorsrc b/.all-contributorsrc index 5a97feac..c60fd019 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -195,7 +195,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/4863062?v=4", "profile": "https://github.com/the-ult", "contributions": [ - "code" + "code", + "maintenance" ] }, { @@ -254,6 +255,217 @@ "contributions": [ "code" ] + }, + { + "login": "MatanBobi", + "name": "Matan Borenkraout", + "avatar_url": "https://avatars.githubusercontent.com/u/12711091?v=4", + "profile": "https://matan.io", + "contributions": [ + "maintenance" + ] + }, + { + "login": "mleimer", + "name": "mleimer", + "avatar_url": "https://avatars.githubusercontent.com/u/14271564?v=4", + "profile": "https://github.com/mleimer", + "contributions": [ + "doc", + "test" + ] + }, + { + "login": "meirka", + "name": "MeIr", + "avatar_url": "https://avatars.githubusercontent.com/u/750901?v=4", + "profile": "https://github.com/meirka", + "contributions": [ + "bug", + "test" + ] + }, + { + "login": "jadengis", + "name": "John Dengis", + "avatar_url": "https://avatars.githubusercontent.com/u/13421336?v=4", + "profile": "https://github.com/jadengis", + "contributions": [ + "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, @@ -261,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 new file mode 100755 index 00000000..f0f7d343 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/bin/sh + +npm run pre-commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e85298ba..b35c5abb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,33 +4,46 @@ on: push: branches: - 'main' + - 'beta' pull_request: {} + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: build_test_release: + permissions: + actions: write + contents: write + strategy: matrix: - node-version: ${{ fromJSON(github.ref == 'refs/heads/main' && '[16]' || '[12,14,16]') }} - os: ${{ fromJSON(github.ref == 'refs/heads/main' && '["ubuntu-latest"]' || '["ubuntu-latest", "windows-latest"]') }} + 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@v2 + - uses: actions/checkout@v4 - name: use Node.js ${{ matrix.node-version }} on ${{ matrix.os }} - uses: actions/setup-node@v2 + 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' + if: github.repository == 'testing-library/angular-testing-library' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') run: npx semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} CI: true - HUSKY: 0 diff --git a/.gitignore b/.gitignore index 82985ca5..22faaca8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,11 @@ !.vscode/extensions.json # misc +/.angular/cache +.angular +.nx +migrations.json +.cache /.sass-cache /connect.lock /coverage @@ -39,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/.husky/.gitignore b/.husky/.gitignore deleted file mode 100644 index 31354ec1..00000000 --- a/.husky/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_ diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index d0612ad3..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "0ドル")/_/husky.sh" - -npm run pre-commit diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..8fdd954d --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/.npmrc b/.npmrc index d2722898..1df2a6d8 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ -registry=http://registry.npmjs.org/ +registry=https://registry.npmjs.org/ package-lock=false diff --git a/.nxignore b/.nxignore new file mode 100644 index 00000000..aeb6b7f1 --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +/projects/vscode-atl-render diff --git a/.prettierignore b/.prettierignore index 2cbf2c26..03ff48d9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -35,6 +35,8 @@ CHANGELOG.md !.vscode/extensions.json # misc +.cache +.angular /.sass-cache /connect.lock /coverage @@ -51,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 029f501a..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,40 +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

💻 -
+ +
+ 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

💻 +
+ @@ -234,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`. @@ -264,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 45bf7eca..00000000 --- a/angular.json +++ /dev/null @@ -1,292 +0,0 @@ -{ - "version": 1, - "cli": { - "analytics": false - }, - "defaultProject": "example-app", - "projects": { - "example-app": { - "projectType": "application", - "root": "apps/example-app", - "sourceRoot": "apps/example-app/src", - "prefix": "app", - "schematics": {}, - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "outputPath": "dist/apps/example-app", - "index": "apps/example-app/src/index.html", - "main": "apps/example-app/src/main.ts", - "polyfills": "apps/example-app/src/polyfills.ts", - "tsConfig": "apps/example-app/tsconfig.app.json", - "assets": ["apps/example-app/src/favicon.ico", "apps/example-app/src/assets"], - "styles": ["apps/example-app/src/styles.css"], - "scripts": [], - "vendorChunk": true, - "extractLicenses": false, - "buildOptimizer": false, - "sourceMap": true, - "optimization": false, - "namedChunks": true - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "anyComponentStyle", - "maximumWarning": "6kb" - } - ], - "fileReplacements": [ - { - "replace": "apps/example-app/src/environments/environment.ts", - "with": "apps/example-app/src/environments/environment.prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "namedChunks": false, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": true - } - }, - "outputs": ["{options.outputPath}"] - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "options": { - "browserTarget": "example-app:build" - }, - "configurations": { - "production": { - "browserTarget": "example-app:build:production" - } - } - }, - "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "browserTarget": "example-app:build" - } - }, - "lint": { - "builder": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": [ - "apps/example-app/**/*.ts", - "apps/example-app/**/*.html", - "apps/example-app/src/**/*.html", - "apps/example-app/src/**/*.html", - "apps/example-app/src/**/*.html" - ] - } - }, - "test": { - "builder": "@nrwl/jest:jest", - "options": { - "jestConfig": "apps/example-app/jest.config.js" - }, - "outputs": ["coverage/"] - } - } - }, - "example-app-karma": { - "projectType": "application", - "root": "apps/example-app-karma", - "sourceRoot": "apps/example-app-karma/src", - "prefix": "app", - "schematics": {}, - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "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": [], - "scripts": [], - "vendorChunk": true, - "extractLicenses": false, - "buildOptimizer": false, - "sourceMap": true, - "optimization": false, - "namedChunks": true - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "anyComponentStyle", - "maximumWarning": "6kb" - } - ], - "fileReplacements": [ - { - "replace": "apps/example-app-karma/src/environments/environment.ts", - "with": "apps/example-app-karma/src/environments/environment.prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "namedChunks": false, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": true - } - }, - "outputs": ["{options.outputPath}"] - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "options": { - "browserTarget": "example-app-karma:build" - }, - "configurations": { - "production": { - "browserTarget": "example-app-karma:build:production" - } - } - }, - "lint": { - "builder": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": [ - "apps/example-app-karma/**/*.ts", - "apps/example-app-karma/**/*.html", - "apps/example-app-karma/src/**/*.html", - "apps/example-app-karma/src/**/*.html", - "apps/example-app-karma/src/**/*.html" - ] - } - }, - "test": { - "builder": "@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", - "styles": [], - "scripts": [], - "assets": [] - } - } - } - }, - "testing-library": { - "root": "projects/testing-library", - "sourceRoot": "projects/testing-library/src", - "projectType": "library", - "prefix": "lib", - "architect": { - "build-package": { - "builder": "@angular-devkit/build-angular:ng-packagr", - "options": { - "tsConfig": "projects/testing-library/tsconfig.lib.json", - "project": "projects/testing-library/ng-package.json" - }, - "configurations": { - "production": { - "project": "projects/testing-library/ng-package.json", - "tsConfig": "projects/testing-library/tsconfig.lib.json" - } - } - }, - "lint": { - "builder": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": [ - "projects/testing-library/**/*.ts", - "projects/testing-library/**/*.html", - "projects/testing-library/src/**/*.html", - "projects/testing-library/src/**/*.html", - "projects/testing-library/src/**/*.html" - ] - } - }, - "build": { - "builder": "@nrwl/workspace:run-commands", - "options": { - "parallel": false, - "commands": [ - { - "command": "ng run testing-library:build-package" - }, - { - "command": "npm run build:schematics" - }, - { - "command": "cpy ./README.md ./dist/@testing-library/angular" - } - ] - } - }, - "test": { - "builder": "@nrwl/jest:jest", - "options": { - "jestConfig": "projects/testing-library/jest.config.js" - }, - "outputs": ["coverage/projects/testing-library"] - } - } - }, - "jest-utils": { - "root": "projects/jest-utils", - "sourceRoot": "projects/jest-utils/src", - "projectType": "library", - "prefix": "lib", - "architect": { - "build-package": { - "builder": "@angular-devkit/build-angular:ng-packagr", - "options": { - "tsConfig": "projects/jest-utils/tsconfig.lib.json", - "project": "projects/jest-utils/ng-package.json" - }, - "configurations": { - "production": { - "project": "projects/jest-utils/ng-package.json", - "tsConfig": "projects/jest-utils/tsconfig.lib.json" - } - } - }, - "lint": { - "builder": "@nrwl/linter:eslint", - "options": { - "lintFilePatterns": [ - "projects/jest-utils/**/*.ts", - "projects/jest-utils/**/*.html", - "projects/jest-utils/src/**/*.html", - "projects/jest-utils/src/**/*.html", - "projects/jest-utils/src/**/*.html" - ] - } - }, - "build": { - "builder": "@nrwl/workspace:run-commands", - "options": { - "parallel": false, - "commands": [ - { - "command": "ng run jest-utils:build-package" - } - ] - } - }, - "test": { - "builder": "@nrwl/jest:jest", - "options": { - "jestConfig": "projects/jest-utils/jest.config.js" - }, - "outputs": ["coverage/projects/jest-utils"] - } - } - } - } -} 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 new file mode 100644 index 00000000..54d79038 --- /dev/null +++ b/apps/example-app-karma/jasmine-dom.d.ts @@ -0,0 +1,4 @@ +declare module '@testing-library/jasmine-dom' { + const JasmineDOM: any; + export default JasmineDOM; +} diff --git a/apps/example-app-karma/project.json b/apps/example-app-karma/project.json new file mode 100644 index 00000000..27c4cbd4 --- /dev/null +++ b/apps/example-app-karma/project.json @@ -0,0 +1,68 @@ +{ + "name": "example-app-karma", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/example-app-karma/src", + "prefix": "app", + "tags": [], + "generators": {}, + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:browser", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/example-app-karma", + "index": "apps/example-app-karma/src/index.html", + "main": "apps/example-app-karma/src/main.ts", + "tsConfig": "apps/example-app-karma/tsconfig.app.json", + "assets": ["apps/example-app-karma/src/favicon.ico", "apps/example-app-karma/src/assets"], + "styles": [], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "anyComponentStyle", + "maximumWarning": "6kb" + } + ], + "outputHashing": "all" + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "executor": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "example-app-karma:build:production" + }, + "development": { + "buildTarget": "example-app-karma:build:development" + } + }, + "defaultConfiguration": "development", + "continuous": true + }, + "lint": { + "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", + "karmaConfig": "apps/example-app-karma/karma.conf.js" + } + } + } +} 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 740d8184..00000000 --- a/apps/example-app-karma/src/app/issues/issue-222.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { render, screen } from '@testing-library/angular'; - -it('https://github.com/testing-library/angular-testing-library/issues/222', async () => { - const { rerender } = await render(`
Hello {{ name}}
`, { - componentProperties: { - name: 'Sarah', - }, - }); - - expect(screen.getByText('Hello Sarah')).toBeTruthy(); - rerender({ 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/jasmine-matchers.spec.ts b/apps/example-app-karma/src/app/issues/jasmine-matchers.spec.ts new file mode 100644 index 00000000..0f6e3fd2 --- /dev/null +++ b/apps/example-app-karma/src/app/issues/jasmine-matchers.spec.ts @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/angular'; + +it('can use jasmine matchers', async () => { + await render(`
Hello {{ name}}
`, { + componentProperties: { + name: 'Sarah', + }, + }); + + expect(screen.getByText('Hello Sarah')).toBeVisible(); +}); 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 85db3caf..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 `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-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 - - - - - -
- -

AltStyle によって変換されたページ (->オリジナル) /

- 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 bb20fec0..00000000 --- a/apps/example-app-karma/src/polyfills.ts +++ /dev/null @@ -1,75 +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 NgClass support on SVG elements */ -// import 'classlist.js'; // Run `npm install --save classlist.js`. - -/** 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. - -/** - * Web Animations `@angular/platform-browser/animations` - * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. - * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). - **/ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - -/** - * 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 bd5e2db8..e6bf956d 100644 --- a/apps/example-app-karma/src/test.ts +++ b/apps/example-app-karma/src/test.ts @@ -1,13 +1,13 @@ -// 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'; -declare const require: any; +// Install custom matchers from jasmine-dom +beforeEach(() => { + jasmine.addMatchers(JasmineDOM); +}); // 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); +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), {}); diff --git a/apps/example-app-karma/tsconfig.app.json b/apps/example-app-karma/tsconfig.app.json index 629fd434..46150c25 100644 --- a/apps/example-app-karma/tsconfig.app.json +++ b/apps/example-app-karma/tsconfig.app.json @@ -3,7 +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 e1d5ba47..9453a196 100644 --- a/apps/example-app-karma/tsconfig.json +++ b/apps/example-app-karma/tsconfig.json @@ -1,8 +1,10 @@ { - "extends": "../../tsconfig.json", + "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 new file mode 100644 index 00000000..1cf90ac4 --- /dev/null +++ b/apps/example-app/project.json @@ -0,0 +1,75 @@ +{ + "name": "example-app", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/example-app/src", + "prefix": "app", + "tags": [], + "generators": {}, + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:browser", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/example-app", + "index": "apps/example-app/src/index.html", + "main": "apps/example-app/src/main.ts", + "polyfills": "apps/example-app/src/polyfills.ts", + "tsConfig": "apps/example-app/tsconfig.app.json", + "assets": ["apps/example-app/src/favicon.ico", "apps/example-app/src/assets"], + "styles": ["apps/example-app/src/styles.css"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "anyComponentStyle", + "maximumWarning": "6kb" + } + ], + "outputHashing": "all" + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "executor": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "example-app:build:production" + }, + "development": { + "buildTarget": "example-app:build:development" + } + }, + "defaultConfiguration": "development", + "continuous": true + }, + "extract-i18n": { + "executor": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "example-app:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "apps/example-app/jest.config.ts", + "passWithNoTests": false + }, + "outputs": ["{workspaceRoot}/coverage/"] + } + } +} 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 1cfa2f59..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 { MasterComponent, 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: MasterComponent, - 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 fb4ccaf7..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 { MasterComponent, 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, - MasterComponent, - 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 5b0faeb2..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,29 +1,33 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; @Component({ - selector: 'app-button', + standalone: true, + selector: 'atl-button', template: ' ', }) export class NestedButtonComponent { - @Input() name: string; + @Input() name = ''; @Output() raise = new EventEmitter(); } @Component({ - selector: 'app-value', + standalone: true, + selector: 'atl-value', template: ' {{ value }} ', }) export class NestedValueComponent { - @Input() value: number; + @Input() value?: number; } @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 663d63e0..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(InputOutputComponent, { - template: '', + 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 6be38a45..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.queryByText('score must be lesser than 10')).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 03fa74f3..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 { FormBuilder, Validators, ValidationErrors } 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,24 +33,25 @@ import { FormBuilder, Validators, ValidationErrors } 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: FormBuilder) {} - get formErrors() { return Object.keys(this.form.controls) .map((formKey) => { - const controlErrors: ValidationErrors = this.form.get(formKey).errors; - if (controlErrors != null) { + const controlErrors = this.form.get(formKey)?.errors; + if (controlErrors) { return Object.keys(controlErrors).map((keyError) => { const error = controlErrors[keyError]; switch (keyError) { @@ -57,9 +61,12 @@ export class FormsComponent { return `${formKey} must be greater than ${error.min}`; case 'max': return `${formKey} must be lesser than ${error.max}`; + default: + return `${formKey} is invalid`; } }); } + return []; }) .reduce((errors, value) => errors.concat(value), []) .filter(Boolean); 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 c215d9c4..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,42 +1,55 @@ 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.queryByText('score must be lesser than 10')).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({ @@ -44,6 +57,45 @@ test('is possible to fill in a form and verify error messages (with the help of score: 7, }); - // not added to the form? - expect((fixture.componentInstance as MaterialFormsComponent).form.get('color').value).toBe('G'); + // 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 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.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 e9a026ce..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 { FormBuilder, Validators, ValidationErrors } 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 }} + --- {{ color.value }} + + Choose a date + + MM/DD/YYYY + + + +

{{ error }}

@@ -52,24 +84,31 @@ import { FormBuilder, Validators, ValidationErrors } 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: ['', Validators.required], + color: [null as string | null, Validators.required], + date: [null as Date | null, Validators.required], + agree: [false, Validators.requiredTrue], }); - constructor(private formBuilder: FormBuilder) {} + get colorControlDisplayValue(): string | undefined { + const selectedId = this.form.get('color')?.value; + return this.colors.filter((color) => color.id === selectedId)[0]?.value; + } get formErrors() { return Object.keys(this.form.controls) .map((formKey) => { - const controlErrors: ValidationErrors = this.form.get(formKey).errors; - if (controlErrors != null) { + const controlErrors = this.form.get(formKey)?.errors; + if (controlErrors) { return Object.keys(controlErrors).map((keyError) => { const error = controlErrors[keyError]; switch (keyError) { @@ -79,9 +118,12 @@ export class MaterialFormsComponent { return `${formKey} must be greater than ${error.min}`; case 'max': return `${formKey} must be lesser than ${error.max}`; + default: + return `${formKey} is invalid`; } }); } + return []; }) .reduce((errors, value) => errors.concat(value), []) .filter(Boolean); 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 f260b978..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,25 +1,24 @@ -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'); const decrement = createAction('decrement'); -const counterReducer = createReducer( +export const reducer = createReducer( 0, on(increment, (state) => state + 1), on(decrement, (state) => state - 1), ); -export function reducer(state, action) { - return counterReducer(state, action); -} - const selectValue = createSelector( (state: any) => state.value, (value) => value * 10, ); @Component({ - selector: 'app-fixture', + standalone: true, + imports: [AsyncPipe], + selector: 'atl-fixture', template: ` {{ value | async }} @@ -27,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 5df9413a..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,32 +1,62 @@ -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'); expect(screen.queryByText('I am visible now...')).not.toBeInTheDocument(); - expect(screen.queryByText('SPOILER')).toBeInTheDocument(); + expect(screen.getByText('SPOILER')).toBeInTheDocument(); - fireEvent.mouseOver(directive); + await user.hover(directive); expect(screen.queryByText('SPOILER')).not.toBeInTheDocument(); - expect(screen.queryByText('I am visible now...')).toBeInTheDocument(); + expect(screen.getByText('I am visible now...')).toBeInTheDocument(); - fireEvent.mouseLeave(directive); - expect(screen.queryByText('SPOILER')).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 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, @@ -34,33 +64,34 @@ test('it is possible to test directives with props', async () => { }); expect(screen.queryByText(visible)).not.toBeInTheDocument(); - expect(screen.queryByText(hidden)).toBeInTheDocument(); + expect(screen.getByText(hidden)).toBeInTheDocument(); - fireEvent.mouseOver(screen.queryByText(hidden)); + await user.hover(screen.getByText(hidden)); expect(screen.queryByText(hidden)).not.toBeInTheDocument(); - expect(screen.queryByText(visible)).toBeInTheDocument(); + expect(screen.getByText(visible)).toBeInTheDocument(); - fireEvent.mouseLeave(screen.queryByText(visible)); - expect(screen.queryByText(hidden)).toBeInTheDocument(); + 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.queryByText(hidden)).toBeInTheDocument(); + expect(screen.getByText(hidden)).toBeInTheDocument(); - fireEvent.mouseOver(screen.queryByText(hidden)); + await user.hover(screen.getByText(hidden)); expect(screen.queryByText(hidden)).not.toBeInTheDocument(); - expect(screen.queryByText(visible)).toBeInTheDocument(); + expect(screen.getByText(visible)).toBeInTheDocument(); - fireEvent.mouseLeave(screen.queryByText(visible)); - expect(screen.queryByText(hidden)).toBeInTheDocument(); + 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 cb6743fc..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,10 +1,49 @@ import { render, screen } from '@testing-library/angular'; +import userEvent from '@testing-library/user-event'; -import { DetailComponent, MasterComponent, HiddenDetailComponent } from './09-router'; +import { DetailComponent, RootComponent, HiddenDetailComponent } from './09-router'; test('it can navigate to routes', async () => { - const { navigate } = await render(MasterComponent, { - declarations: [DetailComponent, HiddenDetailComponent], + const user = userEvent.setup(); + await render(RootComponent, { + routes: [ + { + path: '', + children: [ + { + path: 'detail/:id', + component: DetailComponent, + }, + { + path: 'hidden-detail', + component: HiddenDetailComponent, + }, + ], + }, + ], + }); + + expect(screen.queryByText(/Detail one/i)).not.toBeInTheDocument(); + + await user.click(screen.getByRole('link', { name: /load one/i })); + expect(await screen.findByRole('heading', { name: /Detail one/i })).toBeInTheDocument(); + + 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(); + + await user.click(screen.getByRole('link', { name: /back to parent/i })); + expect(screen.queryByRole('heading', { name: /Detail three/i })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('link', { name: /load two/i })); + expect(await screen.findByRole('heading', { name: /Detail two/i })).toBeInTheDocument(); + + 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, { routes: [ { path: '', @@ -25,25 +64,24 @@ test('it can navigate to routes', async () => { expect(screen.queryByText(/Detail one/i)).not.toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /load one/i })); - expect(screen.queryByRole('heading', { name: /Detail one/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Detail one/i })).toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /load three/i })); expect(screen.queryByRole('heading', { name: /Detail one/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: /Detail three/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Detail three/i })).toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /back to parent/i })); expect(screen.queryByRole('heading', { name: /Detail three/i })).not.toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /load two/i })); - expect(screen.queryByRole('heading', { name: /Detail two/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Detail two/i })).toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /hidden x/i })); - expect(screen.queryByText(/You found the treasure!/i)).toBeInTheDocument(); + expect(screen.getByText(/You found the treasure!/i)).toBeInTheDocument(); }); test('it can navigate to routes with a base path', async () => { const basePath = 'base'; - const { navigate } = await render(MasterComponent, { - declarations: [DetailComponent, HiddenDetailComponent], + const { navigate } = await render(RootComponent, { routes: [ { path: basePath, @@ -64,20 +102,20 @@ test('it can navigate to routes with a base path', async () => { expect(screen.queryByRole('heading', { name: /Detail one/i })).not.toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /load one/i }), basePath); - expect(screen.queryByRole('heading', { name: /Detail one/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Detail one/i })).toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /load three/i }), basePath); expect(screen.queryByRole('heading', { name: /Detail one/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: /Detail three/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Detail three/i })).toBeInTheDocument(); await navigate(screen.getByRole('link', { name: /back to parent/i })); expect(screen.queryByRole('heading', { name: /Detail three/i })).not.toBeInTheDocument(); // It's possible to just use strings await navigate('base/detail/two?text=Hello&subtext=World'); - expect(screen.queryByRole('heading', { name: /Detail two/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Detail two/i })).toBeInTheDocument(); expect(screen.getByText(/Hello World/i)).toBeInTheDocument(); await navigate('/hidden-detail', basePath); - expect(screen.queryByText(/You found the treasure!/i)).toBeInTheDocument(); + expect(screen.getByText(/You found the treasure!/i)).toBeInTheDocument(); }); diff --git a/apps/example-app/src/app/examples/09-router.ts b/apps/example-app/src/app/examples/09-router.ts index 7d4e300d..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 MasterComponent {} +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 14a1630c..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 @@ -1,4 +1,3 @@ -import { TestBed } from '@angular/core/testing'; import { render, screen } from '@testing-library/angular'; import { CellComponent } from './11-ng-content'; @@ -6,9 +5,8 @@ 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!'; - TestBed.overrideComponent(CellComponent, { set: { selector: 'cell' } }); - await render(CellComponent, { - template: `${projection}`, + 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 302563f1..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,6 +1,8 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; @Component({ + 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 af35f10a..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,9 +1,10 @@ -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 { - id: string; - name: string; + id!: string; + name!: string; } @Injectable({ @@ -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 f0fbd465..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,48 +22,54 @@ 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); - await screen.findByRole('dialog'); - await screen.findByRole('heading', { name: /dialog title/i }); + 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 - fireEvent.click(document.querySelector('.cdk-overlay-backdrop')); + await user.click(document.querySelector('.cdk-overlay-backdrop')!); - await waitForElementToBeRemoved(() => screen.getByRole('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); - await screen.findByRole('dialog'); - await screen.findByRole('heading', { name: /dialog title/i }); + const dialogControl = await screen.findByRole('dialog'); + expect(dialogControl).toBeInTheDocument(); + const dialogTitleControl = await screen.findByRole('heading', { name: /dialog title/i }); + expect(dialogTitleControl).toBeInTheDocument(); const cancelButton = await screen.findByRole('button', { name: /cancel/i }); - userEvent.click(cancelButton); + await user.click(cancelButton); - await waitForElementToBeRemoved(() => screen.getByRole('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 28d736c6..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 @@ -12,14 +12,13 @@ test('should run logic in the input setter and getter', async () => { test('should run logic in the input setter and getter while re-rendering', async () => { const { rerender } = 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'); + 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' } }); - expect(valueControl).toHaveTextContent('I am value from setter React'); - expect(getterValueControl).toHaveTextContent('I am value from getter 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'); + expect(screen.getByTestId('value-getter')).toHaveTextContent('I am value from getter 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 11f8c949..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; @@ -18,6 +18,6 @@ export class InputGetterSetter { return 'I am value from getter ' + this.originalValue; } - private originalValue: string; - derivedValue: string; + private originalValue?: string; + derivedValue?: string; } 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 new file mode 100644 index 00000000..f33dee3e --- /dev/null +++ b/apps/example-app/src/app/examples/17-component-with-attribute-selector.spec.ts @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/angular'; +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( + ``, + { + imports: [ComponentWithAttributeSelectorComponent], + }, + ); + + const valueControl = screen.getByTestId('value'); + + expect(valueControl).toHaveTextContent('42'); +}); 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 new file mode 100644 index 00000000..930032c4 --- /dev/null +++ b/apps/example-app/src/app/examples/17-component-with-attribute-selector.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + 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 new file mode 100644 index 00000000..068a8c09 --- /dev/null +++ b/apps/example-app/src/app/examples/18-html-as-input.spec.ts @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/angular'; +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + standalone: true, + name: 'stripHTML', +}) +class StripHTMLPipe implements PipeTransform { + transform(stringValueWithHTML: string): string { + return stringValueWithHTML.replace(/<[^>]*>?/gm, ''); + } +} + +const STRING_WITH_HTML = + 'Some database field
    with stripped HTML
    '; + +// https://github.com/testing-library/angular-testing-library/pull/271 +test('passes HTML as component properties', async () => { + await render(`

    {{ stringWithHtml | stripHTML }}

    `, { + componentProperties: { + stringWithHtml: STRING_WITH_HTML, + }, + 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 }}

    `, { + 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 new file mode 100644 index 00000000..d1d1e0ba --- /dev/null +++ b/apps/example-app/src/app/examples/19-standalone-component.spec.ts @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/angular'; +import { StandaloneComponent, StandaloneWithChildComponent } from './19-standalone-component'; + +test('can render a standalone component', async () => { + await render(StandaloneComponent); + + const content = screen.getByTestId('standalone'); + + expect(content).toHaveTextContent('Standalone Component'); +}); + +test('can render a standalone component with a child', async () => { + await render(StandaloneWithChildComponent, { + componentProperties: { name: 'Bob' }, + }); + + const childContent = screen.getByTestId('standalone'); + expect(childContent).toHaveTextContent('Standalone Component'); + + expect(screen.getByText('Hi Bob')).toBeInTheDocument(); + expect(screen.getByText('This has a child')).toBeInTheDocument(); +}); diff --git a/apps/example-app/src/app/examples/19-standalone-component.ts b/apps/example-app/src/app/examples/19-standalone-component.ts new file mode 100644 index 00000000..95eae3d5 --- /dev/null +++ b/apps/example-app/src/app/examples/19-standalone-component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'atl-standalone', + template: `
    Standalone Component
    `, + standalone: true, +}) +export class StandaloneComponent {} + +@Component({ + selector: 'atl-standalone-with-child', + template: `

    Hi {{ name }}

    +

    This has a child

    + `, + standalone: true, + imports: [StandaloneComponent], +}) +export class StandaloneWithChildComponent { + @Input() + name?: string; +} 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/issues/issue-106.spec.ts b/apps/example-app/src/app/issues/issue-106.spec.ts deleted file mode 100644 index b1f6cc2c..00000000 --- a/apps/example-app/src/app/issues/issue-106.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Component } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; -import { render, screen, fireEvent, waitFor } from '@testing-library/angular'; - -@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 - 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/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 bb20fec0..00000000 --- a/apps/example-app/src/polyfills.ts +++ /dev/null @@ -1,75 +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 NgClass support on SVG elements */ -// import 'classlist.js'; // Run `npm install --save classlist.js`. - -/** 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. - -/** - * Web Animations `@angular/platform-browser/animations` - * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. - * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). - **/ -// import 'web-animations-js'; // Run `npm install --save web-animations-js`. - -/** - * 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 629fd434..b0e22e14 100644 --- a/apps/example-app/tsconfig.app.json +++ b/apps/example-app/tsconfig.app.json @@ -3,7 +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", "jest.config.ts"] } diff --git a/apps/example-app/tsconfig.json b/apps/example-app/tsconfig.json index de50e310..c0e57dc9 100644 --- a/apps/example-app/tsconfig.json +++ b/apps/example-app/tsconfig.json @@ -1,8 +1,10 @@ { - "extends": "../../tsconfig.json", + "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 afe056ed..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", "**/*.d.ts"] + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] } 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 b52842c3..00000000 --- a/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - projects: ['/apps/example-app', '/projects/testing-library', '/projects/jest-utils'], -}; 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 3f434cfc..e0cb70c9 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -1,16 +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|js|html)$': 'ts-jest', + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], }, - resolver: '@nrwl/jest/plugins/resolver', + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + 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/lint-staged.config.js b/lint-staged.config.js index a10d3ccf..00ee30fd 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,4 +1,4 @@ module.exports = { - '*.{ts,js}': ['eslint --fix', 'git add'], - '*.{json,md}': ['prettier --write', 'git add'], + '*.{ts,js}': ['eslint --fix'], + '*.{ts,js,json,md}': ['prettier --write'], }; diff --git a/nx.json b/nx.json index bae85516..a308e678 100644 --- a/nx.json +++ b/nx.json @@ -1,41 +1,107 @@ { - "implicitDependencies": { - "angular.json": "*", - "package.json": { - "dependencies": "*", - "devDependencies": "*" - }, - "tsconfig.base.json": "*", - ".eslintrc.json": "*", - "nx.json": "*" + "workspaceLayout": { + "appsDir": "apps", + "libsDir": "projects" }, - "affected": { - "defaultBase": "main" + "cli": { + "analytics": false, + "cache": { + "enabled": true, + "path": "./.cache/angular", + "environment": "all" + } }, - "npmScope": "testing-library", "tasksRunnerOptions": { "default": { - "runner": "@nrwl/nx-cloud", "options": { - "accessToken": "M2Q4YjlkNjMtMzY1NC00ZjkwLTk1ZjgtZjg5Y2VkMzFjM2FifHJlYWQtd3JpdGU=", - "cacheableOperations": ["build", "test", "lint", "e2e"], "canTrackAnalytics": false, "showUsageWarnings": true } } }, - "projects": { - "example-app": { - "tags": [] + "generators": { + "@nrlw/workspace:library": { + "linter": "eslint", + "unitTestRunner": "jest", + "strict": true, + "standaloneConfig": true, + "buildable": true }, - "example-app-karma": { - "tags": [] + "@nx/angular:application": { + "style": "scss", + "linter": "eslint", + "unitTestRunner": "jest", + "e2eTestRunner": "cypress", + "strict": true, + "standaloneConfig": true, + "tags": ["type:app"] }, - "testing-library": { - "tags": [] + "@nx/angular:library": { + "linter": "eslint", + "unitTestRunner": "jest", + "strict": true, + "standaloneConfig": true, + "publishable": true }, - "jest-utils": { - "tags": [] + "@nx/angular:component": { + "style": "scss", + "displayBlock": true, + "changeDetection": "OnPush" + }, + "@schematics/angular": { + "component": { + "style": "scss", + "displayBlock": true, + "changeDetection": "OnPush" + } } - } + }, + "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 31a14a62..341eb0f5 100644 --- a/package.json +++ b/package.json @@ -2,16 +2,15 @@ "name": "@testing-library/angular-app", "version": "0.0.0-semantically-released", "scripts": { - "ng": "ng", + "ng": "nx", "nx": "nx", - "postinstall": "ngcc", - "start": "ng serve", + "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 && ng lint", - "e2e": "ng e2e", + "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", "affected:build": "nx affected:build", @@ -25,75 +24,86 @@ "format:check": "nx format:check", "pre-commit": "lint-staged", "semantic-release": "semantic-release", - "prepare": "husky install" + "prepare": "git config core.hookspath .githooks" }, "dependencies": { - "@angular/animations": "12.1.1", - "@angular/cdk": "12.1.1", - "@angular/common": "12.1.1", - "@angular/compiler": "12.1.1", - "@angular/core": "12.1.1", - "@angular/forms": "12.1.1", - "@angular/material": "12.1.1", - "@angular/platform-browser": "12.1.1", - "@angular/platform-browser-dynamic": "12.1.1", - "@angular/router": "12.1.1", - "@ngrx/store": "12.2.0", - "@nrwl/angular": "12.5.1", - "@nrwl/nx-cloud": "11.2.0", - "@testing-library/dom": "^8.0.0", - "@testing-library/user-event": "^13.1.9", - "core-js": "^3.6.5", - "rxjs": "^6.5.5", - "tslib": "^2.0.0", - "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": "12.1.0", - "@angular-eslint/eslint-plugin": "^12.2.0", - "@angular-eslint/eslint-plugin-template": "^12.2.0", - "@angular-eslint/template-parser": "^12.2.0", - "@angular/cli": "12.1.0", - "@angular/compiler-cli": "12.1.1", - "@angular/language-service": "12.1.1", - "@nrwl/cli": "12.5.1", - "@nrwl/eslint-plugin-nx": "12.5.1", - "@nrwl/jest": "12.5.1", - "@nrwl/linter": "12.5.1", - "@nrwl/node": "12.5.1", - "@nrwl/nx-plugin": "12.5.1", - "@nrwl/workspace": "12.5.1", - "@testing-library/jasmine-dom": "^1.2.0", - "@testing-library/jest-dom": "^5.11.10", - "@types/jasmine": "^3.5.0", - "@types/jest": "^26.0.23", - "@types/node": "14.14.37", - "@typescript-eslint/eslint-plugin": "4.28.2", - "@typescript-eslint/parser": "4.28.2", - "cpy-cli": "^3.1.1", - "eslint": "^7.25.0", - "eslint-config-prettier": "8.3.0", - "eslint-plugin-import": "2.23.4", - "eslint-plugin-jasmine": "^4.1.2", - "eslint-plugin-jest": "24.3.6", - "eslint-plugin-jest-dom": "3.9.0", - "eslint-plugin-testing-library": "4.9.0", - "husky": "^6.0.0", - "jasmine-core": "^3.7.0", - "jasmine-spec-reporter": "^5.0.0", - "jest": "^27.0.6", - "jest-preset-angular": "9.0.4", - "karma": "^6.3.4", - "karma-chrome-launcher": "^3.1.0", - "karma-jasmine": "^4.0.0", - "karma-jasmine-html-reporter": "^1.5.0", - "lint-staged": "^10.2.11", - "ng-packagr": "12.0.0", - "prettier": "^2.3.0", - "rimraf": "^3.0.2", - "semantic-release": "^17.1.1", - "ts-jest": "^27.0.3", - "ts-node": "9.1.1", - "typescript": "4.2.4" + "@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 09dc20bf..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": ">=23.4.0" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "tslib": "^2.0.0" - } -} 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.lib.json b/projects/jest-utils/tsconfig.lib.json deleted file mode 100644 index 7a6179a2..00000000 --- a/projects/jest-utils/tsconfig.lib.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "../../out-tsc/lib", - "target": "es2015", - "declarationMap": false, - "module": "es2015", - "moduleResolution": "node", - "declaration": true, - "sourceMap": true, - "inlineSources": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "importHelpers": true, - "allowSyntheticDefaultImports": true, - "types": ["jest"], - "lib": ["dom", "es2018"] - }, - "angularCompilerOptions": { - "enableIvy": false, - "skipTemplateCodegen": true, - "strictMetadataEmit": true, - "fullTemplateTypeCheck": true, - "strictInjectionParameters": true, - "flatModuleId": "AUTOGENERATED", - "flatModuleOutFile": "AUTOGENERATED" - }, - "exclude": ["src/test.ts", "**/*.spec.ts"] -} diff --git a/projects/jest-utils/tsconfig.spec.json b/projects/jest-utils/tsconfig.spec.json deleted file mode 100644 index 091c5cf3..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", "**/*.d.ts"] -} diff --git a/projects/testing-library/.eslintrc.json b/projects/testing-library/.eslintrc.json deleted file mode 100644 index 4089aa79..00000000 --- a/projects/testing-library/.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/testing-library/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/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 95% rename from projects/jest-utils/src/lib/create-mock.ts rename to projects/testing-library/jest-utils/src/lib/create-mock.ts index 2796d972..73cbf406 100644 --- a/projects/jest-utils/src/lib/create-mock.ts +++ b/projects/testing-library/jest-utils/src/lib/create-mock.ts @@ -16,7 +16,7 @@ export function createMock(type: Type): Mock { } const descriptor = Object.getOwnPropertyDescriptor(proto, prop); - if (typeof descriptor.value === 'function') { + if (typeof descriptor?.value === 'function') { mock[prop] = jest.fn(); } } 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 86% rename from projects/jest-utils/tests/create-mock.spec.ts rename to projects/testing-library/jest-utils/tests/create-mock.spec.ts index ded7ab1c..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(); @@ -45,10 +45,12 @@ test('provides a mock service', async () => { test('provides a mock service with values', async () => { await render(FixtureComponent, { - providers: [provideMockWithValues(FixtureService, { - bar: 'value', - concat: jest.fn(() => 'a concatenated value') - })], + providers: [ + provideMockWithValues(FixtureService, { + bar: 'value', + concat: jest.fn(() => 'a concatenated value'), + }), + ], }); const service = TestBed.inject(FixtureService); 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 f1922ae6..6ea1a38c 100644 --- a/projects/testing-library/package.json +++ b/projects/testing-library/package.json @@ -26,18 +26,17 @@ "save": "devDependencies" }, "ng-update": { - "migrations": "./schematics/migrations/migration.json" + "migrations": "./schematics/migrations/migrations.json" }, "peerDependencies": { - "@angular/common": ">= 10.0.0", - "@angular/platform-browser": ">= 10.0.0", - "@angular/animations": ">= 10.0.0", - "@angular/router": ">= 10.0.0", - "@angular/core": ">= 10.0.0" + "@angular/common": ">= 20.0.0", + "@angular/platform-browser": ">= 20.0.0", + "@angular/router": ">= 20.0.0", + "@angular/core": ">= 20.0.0", + "@testing-library/dom": "^10.0.0" }, "dependencies": { - "@testing-library/dom": "^8.0.0", - "tslib": "^2.0.0" + "tslib": "^2.3.1" }, "publishConfig": { "access": "public" diff --git a/projects/testing-library/project.json b/projects/testing-library/project.json new file mode 100644 index 00000000..1deb065a --- /dev/null +++ b/projects/testing-library/project.json @@ -0,0 +1,54 @@ +{ + "name": "testing-library", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "projects/testing-library/src", + "prefix": "lib", + "tags": [], + "targets": { + "build-package": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/@testing-library/angular"], + "options": { + "project": "projects/testing-library/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/testing-library/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/testing-library/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "build": { + "executor": "nx:run-commands", + "options": { + "parallel": false, + "commands": [ + { + "command": "nx run testing-library:build-package" + }, + { + "command": "npm run build:schematics" + }, + { + "command": "cpy ./README.md ./dist/@testing-library/angular" + } + ] + } + }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "projects/testing-library/jest.config.ts", + "passWithNoTests": false + }, + "outputs": ["{workspaceRoot}/coverage/projects/testing-library"] + } + } +} 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 54b5f2ee..318bd2be 100644 --- a/projects/testing-library/src/lib/models.ts +++ b/projects/testing-library/src/lib/models.ts @@ -1,8 +1,33 @@ -import { Type, DebugElement } from '@angular/core'; -import { ComponentFixture } from '@angular/core/testing'; +import { + Type, + DebugElement, + ModuleWithProviders, + EventEmitter, + EnvironmentProviders, + Provider, + Signal, + InputSignalWithTransform, +} from '@angular/core'; +import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing'; import { Routes } from '@angular/router'; import { BoundFunction, Queries, queries, Config as dtlConfig, PrettyDOMOptions } from '@testing-library/dom'; +// 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 { /** @@ -20,7 +45,7 @@ export interface RenderResult extend * element: The to be printed HTML element, if not provided it will log the whole component's DOM */ debug: ( - element?: Element | HTMLDocument | (Element | HTMLDocument)[], + element?: Element | Document | (Element | Document)[], maxLength?: number, options?: PrettyDOMOptions, ) => void; @@ -54,25 +79,75 @@ export interface RenderResult extend navigate: (elementOrPath: Element | string, basePath?: string) => Promise; /** * @description - * Re-render the same component with different props. + * Re-render the same component with different properties. + * Properties not passed in again are removed. */ - rerender: (componentProperties: Partial) => void; + rerender: ( + properties?: Pick< + RenderTemplateOptions, + 'componentProperties' | 'componentInputs' | 'inputs' | 'componentOutputs' | 'on' | 'detectChangesOnRender' +> & { partialUpdate?: boolean }, + ) => Promise; + /** + * @description + * Set the state of a deferrable block. + */ + 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. @@ -83,11 +158,11 @@ export interface RenderComponentOptions | unknown[])[]; /** * @description * A collection of providers needed to render the component via Dependency Injection, for example, injectable services or tokens. @@ -98,7 +173,7 @@ export interface RenderComponentOptions | ModuleWithProviders)[]; /** * @description * A collection of schemas needed to render the component. @@ -139,7 +213,7 @@ export interface RenderComponentOptions { ... } @@ -162,6 +236,77 @@ 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 * A collection of providers to inject dependencies of the component. @@ -172,13 +317,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. @@ -190,7 +369,7 @@ export interface RenderComponentOptions - extends RenderComponentOptions { /** * @description - * The template to render the directive. - * This template will override the template from the WrapperComponent. + * Callback to configure the testbed before the compilation. + * + * @default + * () => {} * * @example - * const component = await render(SpoilerDirective, { - * template: `
    ` + * await render(AppComponent, { + * configureTestBed: (testBed) => { } * }) - * - * @deprecated Use `render(template, { declarations: [SomeDirective] })` instead. */ - template: string; + configureTestBed?: (testbed: TestBed) => void; + /** * @description - * An Angular component to wrap the component in. - * The template will be overridden with the `template` option. - * - * @default - * `WrapperComponent`, an empty component that strips the `ng-version` attribute - * - * @example - * const component = await render(SpoilerDirective, { - * template: `
    ` - * wrapper: CustomWrapperComponent - * }) + * Set the initial state of a deferrable block. */ - wrapper?: Type; - componentProperties?: Partial; + 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(SpoilerDirective, { - * template: `
    ` + * await render(`
    `, { + * declarations: [SpoilerDirective] * wrapper: CustomWrapperComponent * }) */ @@ -316,5 +505,5 @@ export interface Config extends Pick, 'excludeCompon /** * Imports that are added to the imports */ - defaultImports: any[]; + defaultImports?: (Type | ModuleWithProviders)[]; } diff --git a/projects/testing-library/src/lib/testing-library.ts b/projects/testing-library/src/lib/testing-library.ts index 95f28fc7..46677271 100644 --- a/projects/testing-library/src/lib/testing-library.ts +++ b/projects/testing-library/src/lib/testing-library.ts @@ -1,44 +1,50 @@ import { + ApplicationInitStatus, ChangeDetectorRef, Component, - Type, NgZone, - SimpleChange, OnChanges, + OutputRef, + OutputRefSubscription, + Provider, + SimpleChange, SimpleChanges, - ApplicationInitStatus, + Type, + isStandalone, } 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, waitForOptions as dtlWaitForOptions, - configure as dtlConfigure, + within as dtlWithin, } from '@testing-library/dom'; -import { RenderComponentOptions, RenderDirectiveOptions, 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, renderOptions?: RenderComponentOptions, ): Promise>; -/** - * @deprecated Use `render(template, { declarations: [DirectiveType] })` instead. - */ -export async function render( - component: Type, - renderOptions?: RenderDirectiveOptions, -): Promise>; export async function render( template: string, renderOptions?: RenderTemplateOptions, @@ -46,33 +52,46 @@ export async function render( export async function render( sut: Type | string, - renderOptions: - | RenderComponentOptions - | RenderDirectiveOptions - | RenderTemplateOptions = {}, + renderOptions: RenderComponentOptions | RenderTemplateOptions = {}, ): Promise> { const { dom: domConfig, ...globalConfig } = getConfig(); const { - detectChanges: detectChangesOnRender = true, + detectChangesOnRender = true, + autoDetectChanges = true, declarations = [], imports = [], providers = [], schemas = [], queries, - template = undefined, - wrapper = WrapperComponent, + wrapper = WrapperComponent as Type, componentProperties = {}, + componentInputs = {}, + componentOutputs = {}, + inputs: newInputs = {}, + on = {}, componentProviders = [], + childComponentOverrides = [], + componentImports, excludeComponentDeclaration = false, - routes, + 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, @@ -82,158 +101,266 @@ export async function render( declarations: addAutoDeclarations(sut, { declarations, excludeComponentDeclaration, - template, wrapper, }), - imports: addAutoImports({ + imports: addAutoImports(sut, { imports: imports.concat(defaultImports), routes, }), - providers: [...providers], + providers, schemas: [...schemas], + deferBlockBehavior: deferBlockBehavior ?? DeferBlockBehavior.Manual, }); + overrideComponentImports(sut, componentImports); + overrideChildComponentProviders(childComponentOverrides); - if (componentProviders) { - componentProviders - .reduce((acc, provider) => acc.concat(provider), []) - .forEach((p) => { - const { provide, ...provider } = p; - TestBed.overrideProvider(provide, provider); - }); - } - - const fixture = await createComponentFixture(sut, { template, wrapper }); - setComponentProperties(fixture, { componentProperties }); - - if (removeAngularAttributes) { - fixture.nativeElement.removeAttribute('ng-version'); - const idAttribute = fixture.nativeElement.getAttribute('id'); - if (idAttribute && idAttribute.startsWith('root')) { - fixture.nativeElement.removeAttribute('id'); - } - } - - mountedFixtures.add(fixture); + configureTestBed(TestBed); await TestBed.compileComponents(); - let isAlive = true; - fixture.componentRef.onDestroy(() => (isAlive = false)); - - function detectChanges() { - if (isAlive) { - fixture.detectChanges(); - } - } - - // Call ngOnChanges on initial render - if (hasOnChangesHook(fixture.componentInstance)) { - const changes = getChangesObj(null, componentProperties); - fixture.componentInstance.ngOnChanges(changes); - } - - if (detectChangesOnRender) { - detectChanges(); + // 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 rerender = (rerenderedProperties: Partial) => { - const changes = getChangesObj(fixture.componentInstance, rerenderedProperties); - - setComponentProperties(fixture, { componentProperties: rerenderedProperties }); - - if (hasOnChangesHook(fixture.componentInstance)) { - fixture.componentInstance.ngOnChanges(changes); - } - - fixture.componentRef.injector.get(ChangeDetectorRef).detectChanges(); - }; - - let router = routes ? inject(Router) : null; - const zone = inject(NgZone); - const navigate = async (elementOrPath: Element | string, basePath = '') => { - if (!router) { - router = inject(Router); - } + const componentContainer = createComponentFixture(sut, wrapper); + 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('='); - // TODO(Breaking): group same keys qp[key] ? [...qp[key], value] : value - qp[key] = value; + 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 = queryParams + const navigateOptions: NavigationExtras | undefined = queryParams ? { queryParams, } : undefined; - const doNavigate = () => (navigateOptions ? router.navigate([path], navigateOptions) : router.navigate([path])); + const doNavigate = () => { + return navigateOptions ? router?.navigate([path], navigateOptions) : router?.navigate([path]); + }; let result; if (zone) { - await zone.run(() => (result = doNavigate())); + await zone.run(() => { + result = doNavigate(); + }); } else { result = doNavigate(); } + return result ?? false; + }; + + if (initialRoute) await _navigate(initialRoute); + + 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); + setComponentProperties(createdFixture, properties); + setComponentInputs(createdFixture, inputs); + setComponentOutputs(createdFixture, outputs); + subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo); + + if (removeAngularAttributes) { + createdFixture.nativeElement.removeAttribute('ng-version'); + const idAttribute = createdFixture.nativeElement.getAttribute('id'); + if (idAttribute?.startsWith('root')) { + createdFixture.nativeElement.removeAttribute('id'); + } + } + + mountedFixtures.add(createdFixture); + + let isAlive = true; + createdFixture.componentRef.onDestroy(() => { + isAlive = false; + }); + + if (hasOnChangesHook(createdFixture.componentInstance) && Object.keys(properties).length> 0) { + const changes = getChangesObj(null, componentProperties); + createdFixture.componentInstance.ngOnChanges(changes); + } + + detectChanges = () => { + if (isAlive) { + createdFixture.detectChanges(); + } + }; + if (detectChangesOnRender) { + detectChanges(); + } + + return createdFixture; + }; + + const fixture = await renderFixture(componentProperties, allInputs as any, componentOutputs, on); + + if (deferBlockStates) { + if (Array.isArray(deferBlockStates)) { + 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: () => detectChanges(), navigate, rerender, - debugElement: typeof sut === 'string' ? fixture.debugElement : fixture.debugElement.query(By.directive(sut)), + renderDeferBlock: async (deferBlockState: DeferBlockState, deferBlockIndex?: number) => { + await renderDeferBlock(fixture, deferBlockState, deferBlockIndex); + }, + debugElement: fixture.debugElement, container: fixture.nativeElement, - debug: (element = fixture.nativeElement, maxLength, options) => - Array.isArray(element) - ? element.forEach((e) => console.log(dtlPrettyDOM(e, maxLength, options))) - : console.log(dtlPrettyDOM(element, maxLength, options)), + debug: (element = fixture.nativeElement, maxLength, options) => { + if (Array.isArray(element)) { + for (const e of element) { + console.log(dtlPrettyDOM(e, maxLength, options)); + } + } else { + console.log(dtlPrettyDOM(element, maxLength, options)); + } + }, ...replaceFindWithFindAndDetectChanges(dtlGetQueriesForElement(fixture.nativeElement, queries)), }; } async function createComponent(component: Type): Promise> { /* Make sure angular application is initialized before creating component */ - await inject(ApplicationInitStatus).donePromise; + await TestBed.inject(ApplicationInitStatus).donePromise; return TestBed.createComponent(component); } -async function createComponentFixture( +function createComponentFixture( sut: Type | string, - { template, wrapper }: Pick, 'template' | 'wrapper'>, -): Promise> { + wrapper: Type, +): Type { if (typeof sut === 'string') { TestBed.overrideTemplate(wrapper, sut); - return createComponent(wrapper); - } - if (template) { - TestBed.overrideTemplate(wrapper, template); - return createComponent(wrapper); + return wrapper; } - return createComponent(sut); + return sut; } function setComponentProperties( fixture: ComponentFixture, - { componentProperties = {} }: Pick, 'componentProperties'>, + componentProperties: RenderTemplateOptions['componentProperties'] = {}, ) { for (const key of Object.keys(componentProperties)) { - const descriptor: PropertyDescriptor = Object.getOwnPropertyDescriptor( - fixture.componentInstance.constructor.prototype, - key, - ); + const descriptor = Object.getOwnPropertyDescriptor((fixture.componentInstance as any).constructor.prototype, key); let _value = componentProperties[key]; const defaultGetter = () => _value; - const extendedSetter = (value) => { + const extendedSetter = (value: any) => { _value = value; descriptor?.set?.call(fixture.componentInstance, _value); fixture.detectChanges(); @@ -253,53 +380,166 @@ 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), - }), - {}, - ); + 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( sut: Type | string, { - declarations, + declarations = [], excludeComponentDeclaration, - template, wrapper, - }: Pick, 'declarations' | 'excludeComponentDeclaration' | 'template' | '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 wrappers = () => (template ? [wrapper] : []); - - const components = () => (excludeComponentDeclaration ? [] : [sut]); + const components = () => (excludeComponentDeclaration || isStandalone(sut) ? [] : [sut]); + return [...nonStandaloneDeclarations, ...components()]; +} - return [...declarations, ...wrappers(), ...components()]; +function addAutoImports( + sut: Type | string, + { imports = [], routes }: Pick, 'imports' | 'routes'>, +) { + const routing = () => (routes ? [RouterTestingModule.withRoutes(routes)] : []); + const components = () => (typeof sut !== 'string' && isStandalone(sut) ? [sut] : []); + return [...imports, ...components(), ...routing()]; } -function addAutoImports({ imports, routes }: Pick, 'imports' | 'routes'>) { - const animations = () => { - const animationIsDefined = - imports.indexOf(NoopAnimationsModule)> -1 || imports.indexOf(BrowserAnimationsModule)> -1; - return animationIsDefined ? [] : [NoopAnimationsModule]; - }; +async function renderDeferBlock( + fixture: ComponentFixture, + deferBlockState: DeferBlockState, + deferBlockIndex?: number, +) { + const deferBlockFixtures = await fixture.getDeferBlocks(); - const routing = () => (routes ? [RouterTestingModule.withRoutes(routes)] : []); + if (deferBlockIndex !== undefined) { + if (deferBlockIndex < 0) { + throw new Error('deferBlockIndex must be a positive number'); + } - return [...imports, ...animations(), ...routing()]; + 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); + } + } } /** @@ -313,12 +553,10 @@ async function waitForWrapper( let inFakeAsync = true; try { tick(0); - } catch (err) { + } catch { inFakeAsync = false; } - detectChanges(); - return await dtlWaitFor(() => { setTimeout(() => detectChanges(), 0); if (inFakeAsync) { @@ -336,19 +574,19 @@ async function waitForElementToBeRemovedWrapper( callback: (() => T) | T, options?: dtlWaitForOptions, ): Promise { - let cb; + let cb: () => T; if (typeof callback !== 'function') { const elements = (Array.isArray(callback) ? callback : [callback]) as Element[]; const getRemainingElements = elements.map((element) => { - let parent = element.parentElement; + let parent = element.parentElement as Element; while (parent.parentElement) { parent = parent.parentElement; } return () => (parent.contains(element) ? element : null); }); - cb = () => getRemainingElements.map((c) => c()).filter(Boolean); + cb = () => getRemainingElements.map((c) => c()).find(Boolean) as unknown as T; } else { - cb = callback; + cb = callback as () => T; } return await dtlWaitForElementToBeRemoved(() => { @@ -362,12 +600,15 @@ function cleanup() { mountedFixtures.forEach(cleanupAtFixture); } -function cleanupAtFixture(fixture) { +function cleanupAtFixture(fixture: ComponentFixture) { fixture.destroy(); 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); } @@ -383,48 +624,42 @@ if (typeof process === 'undefined' || !process.env?.ATL_SKIP_AUTO_CLEANUP) { } } -// TODO: rename to `atl-wrapper-component` -// eslint-disable-next-line @angular-eslint/component-selector -@Component({ selector: 'wrapper-component', template: '' }) +@Component({ selector: 'atl-wrapper-component', template: '', standalone: false }) class WrapperComponent {} /** * Wrap findBy queries to poke the Angular change detection cycle */ -function replaceFindWithFindAndDetectChanges(originalQueriesForContainer: T): T { +function replaceFindWithFindAndDetectChanges>(originalQueriesForContainer: T): T { return Object.keys(originalQueriesForContainer).reduce((newQueries, key) => { const getByQuery = originalQueriesForContainer[key.replace('find', 'get')]; if (key.startsWith('find') && getByQuery) { - newQueries[key] = async (text, options, waitOptions) => { + newQueries[key] = async (...queryOptions: any[]) => { + const waitOptions = queryOptions.length === 3 ? queryOptions.pop() : undefined; // original implementation at https://github.com/testing-library/dom-testing-library/blob/main/src/query-helpers.js - const result = await waitForWrapper( - detectChangesForMountedFixtures, - () => getByQuery(text, options), - waitOptions, - ); - return result; + return await waitForWrapper(detectChangesForMountedFixtures, () => getByQuery(...queryOptions), waitOptions); }; } else { newQueries[key] = originalQueriesForContainer[key]; } return newQueries; - }, {} as T); + }, {} as Record) as T; } /** * Call detectChanges for all fixtures */ function detectChangesForMountedFixtures() { - mountedFixtures.forEach((fixture) => { + for (const fixture of mountedFixtures) { try { fixture.detectChanges(); - } catch (err) { + } catch (err: any) { if (!err.message.startsWith('ViewDestroyedError')) { throw err; } } - }); + } } /** @@ -432,6 +667,17 @@ function detectChangesForMountedFixtures() { */ const screen = replaceFindWithFindAndDetectChanges(dtlScreen); +/** + * Re-export within with patched queries + */ +const within = ( + element: HTMLElement, + queriesToBind?: T, +): BoundFunctions => { + const container = dtlWithin(element, queriesToBind); + return replaceFindWithFindAndDetectChanges(container); +}; + /** * Re-export waitFor with patched waitFor */ @@ -516,8 +762,7 @@ export { queryAllByAttribute, queryByAttribute, queryHelpers, - within, } from '@testing-library/dom'; // export patched dtl -export { screen, waitFor, waitForElementToBeRemoved }; +export { screen, waitFor, waitForElementToBeRemoved, within }; 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/auto-cleanup.spec.ts b/projects/testing-library/tests/auto-cleanup.spec.ts index c69eda62..1e37f242 100644 --- a/projects/testing-library/tests/auto-cleanup.spec.ts +++ b/projects/testing-library/tests/auto-cleanup.spec.ts @@ -6,7 +6,7 @@ import { render } from '../src/public_api'; template: `Hello {{ name }}!`, }) class FixtureComponent { - @Input() name: string; + @Input() name = ''; } describe('Angular auto clean up - previous components only get cleanup up on init (based on root-id)', () => { diff --git a/projects/testing-library/tests/config.spec.ts b/projects/testing-library/tests/config.spec.ts index ccd004f5..7783961a 100644 --- a/projects/testing-library/tests/config.spec.ts +++ b/projects/testing-library/tests/config.spec.ts @@ -1,6 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { render, configure } from '../src/public_api'; +import { render, configure, Config } from '../src/public_api'; import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; @Component({ @@ -13,21 +13,21 @@ 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; +let originalConfig: Config; beforeEach(() => { // Grab the existing configuration so we can restore // it at the end of the test configure((existingConfig) => { - originalConfig = existingConfig; + originalConfig = existingConfig as Config; // Don't change the existing config return {}; }); 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 49e9094e..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,17 +51,20 @@ 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), - switchMap((q) => this.entitiesService.fetchAll().pipe(map((ent) => ent.filter((e) => e.name.includes(q))))), + switchMap((q) => + this.entitiesService.fetchAll().pipe(map((ent: any) => ent.filter((e: any) => e.name.includes(q)))), + ), startWith(entities), ); - constructor(private entitiesService: EntitiesService, private modalService: ModalService) {} - newEntityClicked() { this.modalService.open('new entity'); } @@ -53,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, @@ -84,11 +91,11 @@ const entities = [ }, ]; -it('renders the table', async () => { +async function setup() { jest.useFakeTimers(); + const user = userEvent.setup(); await render(EntitiesComponent, { - declarations: [TableComponent], providers: [ { provide: EntitiesService, @@ -104,31 +111,53 @@ it('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(); +}); + +test.skip('finds the cell', async () => { + const { user } = await setup(); - userEvent.type(await screen.findByRole('textbox', { name: /Search entities/i }), 'Entity 2', {}); + 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, }), ); - waitFor(() => expect(modalMock.open).toHaveBeenCalledWith('edit entity', 'Entity 2')); + await waitFor(() => expect(modalMock.open).toHaveBeenCalledWith('edit entity', 'Entity 2')); }); 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-188.spec.ts b/projects/testing-library/tests/issues/issue-188.spec.ts index 8077358a..b150dacc 100644 --- a/projects/testing-library/tests/issues/issue-188.spec.ts +++ b/projects/testing-library/tests/issues/issue-188.spec.ts @@ -6,9 +6,9 @@ import { render, screen } from '../../src/public_api'; template: `

Hello {{ formattedName }}

`, }) class BugOnChangeComponent implements OnChanges { - @Input() name: string; + @Input() name?: string; - formattedName: string; + formattedName?: string; ngOnChanges(changes: SimpleChanges) { if (changes.name) { 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 new file mode 100644 index 00000000..ea230e78 --- /dev/null +++ b/projects/testing-library/tests/issues/issue-280.spec.ts @@ -0,0 +1,59 @@ +import { Location } from '@angular/common'; +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
+ `, + imports: [RouterOutlet], +}) +class MainComponent {} + +@Component({ + template: `
first page
+ go to second`, + imports: [RouterLink], +}) +class FirstComponent {} + +@Component({ + template: `
second page
+ `, +}) +class SecondComponent { + private location = inject(Location); + goBack() { + this.location.back(); + } +} + +const routes: Routes = [ + { path: '', redirectTo: '/first', pathMatch: 'full' }, + { path: 'first', component: FirstComponent }, + { path: 'second', component: SecondComponent }, +]; + +@NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule], +}) +class AppRoutingModule {} + +test('navigate to second page and back', async () => { + await render(MainComponent, { imports: [AppRoutingModule, RouterTestingModule] }); + + expect(await screen.findByText('Navigate')).toBeInTheDocument(); + expect(await screen.findByText('first page')).toBeInTheDocument(); + + await userEvent.click(await screen.findByText('go to second')); + + expect(await screen.findByText('second page')).toBeInTheDocument(); + expect(await screen.findByText('navigate back')).toBeInTheDocument(); + + 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 60c2a074..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 @@ -62,18 +62,9 @@ test('the component renders', async () => { expect(screen.getByText('Hello Angular!')).toBeInTheDocument(); }); -test('the directive renders (compatibility with the deprecated signature)', async () => { - const view = await render(OnOffDirective, { - template: '', - }); - - // eslint-disable-next-line testing-library/no-container - expect(view.container.querySelector('[onoff]')).toBeInTheDocument(); -}); - test('uses the default props', async () => { await render('', { - declarations: [OnOffDirective], + imports: [OnOffDirective], }); fireEvent.click(screen.getByText('init')); @@ -83,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')); @@ -94,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', }, @@ -109,7 +100,7 @@ test('overrides output properties', async () => { const clicked = jest.fn(); await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], componentProperties: { clicked, }, @@ -125,7 +116,7 @@ test('overrides output properties', async () => { describe('removeAngularAttributes', () => { it('should remove angular attributes', async () => { await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], removeAngularAttributes: true, }); @@ -135,7 +126,7 @@ describe('removeAngularAttributes', () => { it('is disabled by default', async () => { await render('
', { - declarations: [OnOffDirective], + imports: [OnOffDirective], }); expect(document.querySelector('[ng-version]')).not.toBeNull(); @@ -144,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 678da79c..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, screen } 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, screen } 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 c98bd1bc..04b8185a 100644 --- a/projects/testing-library/tests/rerender.spec.ts +++ b/projects/testing-library/tests/rerender.spec.ts @@ -1,66 +1,188 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } 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: ` {{ name }} `, + template: ` {{ firstName }} {{ lastName }} `, }) -class FixtureComponent { - @Input() name = 'Sarah'; +class FixtureComponent implements OnChanges { + @Input() firstName = 'Sarah'; + @Input() lastName?: string; + ngOnChanges(changes: SimpleChanges): void { + ngOnChangesSpy(changes); + } } -test('will rerender the component with updated props', async () => { +beforeEach(() => { + ngOnChangesSpy = jest.fn(); +}); + +test('rerenders the component with updated props', async () => { const { rerender } = await render(FixtureComponent); expect(screen.getByText('Sarah')).toBeInTheDocument(); - const name = 'Mark'; - rerender({ name }); + const firstName = 'Mark'; + await rerender({ componentProperties: { firstName } }); - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(firstName)).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('rerenders without props', async () => { + const { rerender } = await render(FixtureComponent); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + + await rerender(); -test('will call ngOnChanges on rerender', async () => { - const nameChanged = jest.fn(); - const componentProperties = { nameChanged }; - const { rerender } = await render(FixtureWithNgOnChangesComponent, { componentProperties }); expect(screen.getByText('Sarah')).toBeInTheDocument(); + expect(ngOnChangesSpy).toHaveBeenCalledTimes(1); // one time initially and one time for rerender +}); - const name = 'Mark'; - rerender({ name }); +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(name)).toBeInTheDocument(); - expect(nameChanged).toHaveBeenCalledWith(name, false); + expect(screen.getByText(firstName)).toBeInTheDocument(); }); -@Component({ - changeDetection: ChangeDetectionStrategy.OnPush, - selector: 'atl-fixture', - template: `
Number
`, -}) -class FixtureWithOnPushComponent { - @Input() activeField: string; -} +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, { + componentProperties: { + firstName, + lastName, + }, + }); + + expect(screen.getByText(`${firstName} ${lastName}`)).toBeInTheDocument(); -test('update properties on rerender', async () => { - const { rerender } = await render(FixtureWithOnPushComponent); - const numberHtmlElementRef = screen.queryByTestId('number'); + const firstName2 = 'Chris'; + await rerender({ componentProperties: { 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, + }, + }); +}); - expect(numberHtmlElementRef).not.toHaveClass('active'); - rerender({ activeField: 'number' }); - expect(numberHtmlElementRef).toHaveClass('active'); +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 new file mode 100644 index 00000000..21a2b8ef --- /dev/null +++ b/projects/testing-library/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.lib.prod.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "target": "es2020" + }, + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true, + "flatModuleId": "AUTOGENERATED", + "flatModuleOutFile": "AUTOGENERATED" + } +} diff --git a/projects/testing-library/tsconfig.lib.json b/projects/testing-library/tsconfig.lib.json index 8506c888..0938741e 100644 --- a/projects/testing-library/tsconfig.lib.json +++ b/projects/testing-library/tsconfig.lib.json @@ -1,28 +1,14 @@ { - "extends": "../../tsconfig.json", + "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "../../out-tsc/lib", - "declarationMap": false, - "target": "es2015", - "module": "ES2015", - "moduleResolution": "node", + "outDir": "../../dist/out-tsc", "declaration": true, - "sourceMap": true, + "declarationMap": true, "inlineSources": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "importHelpers": false, "types": ["node", "jest"], - "lib": ["dom", "es2018"] + "target": "ES2022", + "useDefineForClassFields": false }, - "angularCompilerOptions": { - "enableIvy": false, - "skipTemplateCodegen": true, - "strictMetadataEmit": true, - "fullTemplateTypeCheck": true, - "strictInjectionParameters": true, - "flatModuleId": "AUTOGENERATED", - "flatModuleOutFile": "AUTOGENERATED" - }, - "exclude": ["src/test.ts", "**/*.spec.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 new file mode 100644 index 00000000..752ed5ea --- /dev/null +++ b/projects/testing-library/tsconfig.lib.prod.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "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 91de9574..c0118513 100644 --- a/projects/testing-library/tsconfig.schematics.json +++ b/projects/testing-library/tsconfig.schematics.json @@ -1,17 +1,18 @@ { - "extends": "../../tsconfig.json", + "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 091c5cf3..9fee53b3 100644 --- a/projects/testing-library/tsconfig.spec.json +++ b/projects/testing-library/tsconfig.spec.json @@ -1,9 +1,9 @@ { - "extends": "../../tsconfig.json", + "extends": "./tsconfig.json", "compilerOptions": { "module": "commonjs", "types": ["node", "jest", "@testing-library/jest-dom"] }, "files": ["test-setup.ts"], - "include": ["**/*.spec.ts", "**/*.d.ts"] + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] } diff --git a/projects/vscode-atl-render/.vscode/launch.json b/projects/vscode-atl-render/.vscode/launch.json index 0e191b59..4058e694 100644 --- a/projects/vscode-atl-render/.vscode/launch.json +++ b/projects/vscode-atl-render/.vscode/launch.json @@ -3,15 +3,13 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 { - "version": "0.2.0", - "configurations": [ - { - "name": "Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ] - } - ] -} \ No newline at end of file + "version": "0.2.0", + "configurations": [ + { + "name": "Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"] + } + ] +} 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 new file mode 100644 index 00000000..b75283e1 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,30 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "declaration": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "lib": ["es2018", "dom"], + "module": "esnext", + "moduleResolution": "node", + "outDir": "./dist/out-tsc", + "sourceMap": true, + "target": "ES2020", + "typeRoots": ["node_modules/@types"], + "strict": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "paths": { + "@testing-library/angular": ["projects/testing-library"], + "@testing-library/angular/jest-utils": ["projects/testing-library/jest-utils"] + } + }, + "exclude": ["node_modules", "tmp"] +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 69eb7ed5..00000000 --- a/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compileOnSave": false, - "compilerOptions": { - "baseUrl": "./", - "importHelpers": true, - "module": "esnext", - "outDir": "./dist/out-tsc", - "sourceMap": true, - "declaration": false, - "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es2015", - "typeRoots": ["node_modules/@types"], - "lib": ["es2017", "dom"], - "paths": { - "@testing-library/angular": ["projects/testing-library"], - "@testing-library/angular/jest-utils": ["projects/jest-utils"] - } - } -}