diff --git a/.gitignore b/.gitignore
index 0eaae4fac..0e3afa6bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,72 +1,75 @@
-# See http://help.github.com/ignore-files/ for more about ignoring files.
-
-.matomo
-junit.xml
-
-# compiled output
-/dist
-/tmp
-/out-tsc
-
-/
-
-# dependencies
-/node_modules
-
-# profiling files
-chrome-profiler-events.json
-speed-measure-plugin.json
-
-# IDEs and editors
-/.idea
-.project
-.classpath
-.c9/
-*.launch
-.settings/
-*.sublime-workspace
-
-# IDE - VSCode
-.vscode/*
-!.vscode/settings.json
-!.vscode/tasks.json
-!.vscode/launch.json
-!.vscode/extensions.json
-.history/*
-
-# misc
-/.angular/cache
-/.sass-cache
-/connect.lock
-/coverage
-/testresults
-/libpeerconnection.log
-npm-debug.log
-yarn-error.log
-yarn.lock
-testem.log
-/typings
-
-# System Files
-.DS_Store
-Thumbs.db
-
-libs/swagger/src/lib/*
-*storybook.log
-
-
-.nx/cache
-.nx/workspace-data
-.angular
-.claude
-
-
-storybook-static
-
-.cursor\rules\nx-rules.mdc
-.github\instructions\nx.instructions.md
-.cursor/rules/nx-rules.mdc
-.github/instructions/nx.instructions.md
-
-vite.config.*.timestamp*
-vitest.config.*.timestamp*
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+.matomo
+junit.xml
+
+# compiled output
+/dist
+/tmp
+/out-tsc
+
+/
+
+# dependencies
+/node_modules
+
+# profiling files
+chrome-profiler-events.json
+speed-measure-plugin.json
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# misc
+/.angular/cache
+/.sass-cache
+/connect.lock
+/coverage
+/testresults
+/libpeerconnection.log
+npm-debug.log
+yarn-error.log
+yarn.lock
+testem.log
+/typings
+
+# System Files
+.DS_Store
+Thumbs.db
+
+libs/swagger/src/lib/*
+*storybook.log
+
+
+.nx/cache
+.nx/workspace-data
+.angular
+.claude
+
+
+storybook-static
+
+.cursor\rules\nx-rules.mdc
+.github\instructions\nx.instructions.md
+.cursor/rules/nx-rules.mdc
+.github/instructions/nx.instructions.md
+
+vite.config.*.timestamp*
+vitest.config.*.timestamp*
+
+.mcp.json
+.memory.json
diff --git a/apps/isa-app/src/app/app-routing.module.ts b/apps/isa-app/src/app/app-routing.module.ts
index 72b667ee3..a1315b2f0 100644
--- a/apps/isa-app/src/app/app-routing.module.ts
+++ b/apps/isa-app/src/app/app-routing.module.ts
@@ -1,214 +1,231 @@
-import { isDevMode, NgModule } from '@angular/core';
-import { RouterModule, Routes } from '@angular/router';
-import {
- CanActivateCartGuard,
- CanActivateCartWithProcessIdGuard,
- CanActivateCustomerGuard,
- CanActivateCustomerOrdersGuard,
- CanActivateCustomerOrdersWithProcessIdGuard,
- CanActivateCustomerWithProcessIdGuard,
- CanActivateGoodsInGuard,
- CanActivateProductGuard,
- CanActivateProductWithProcessIdGuard,
- CanActivateRemissionGuard,
- CanActivateTaskCalendarGuard,
- IsAuthenticatedGuard,
-} from './guards';
-import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
-import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
-import { MainComponent } from './main.component';
-import { PreviewComponent } from './preview';
-import {
- BranchSectionResolver,
- CustomerSectionResolver,
- ProcessIdResolver,
-} from './resolvers';
-import { TokenLoginComponent, TokenLoginModule } from './token-login';
-import { ProcessIdGuard } from './guards/process-id.guard';
-import {
- ActivateProcessIdGuard,
- ActivateProcessIdWithConfigKeyGuard,
-} from './guards/activate-process-id.guard';
-import { MatomoRouteData } from 'ngx-matomo-client';
-import { tabResolverFn } from '@isa/core/tabs';
-import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
-
-const routes: Routes = [
- { path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
- {
- path: 'login',
- children: [
- { path: ':token', component: TokenLoginComponent },
- { path: '**', redirectTo: 'kunde', pathMatch: 'full' },
- ],
- },
- {
- path: '',
- canActivate: [IsAuthenticatedGuard],
- children: [
- {
- path: 'kunde',
- component: MainComponent,
- children: [
- {
- path: 'dashboard',
- loadChildren: () =>
- import('@page/dashboard').then((m) => m.DashboardModule),
- data: {
- matomo: {
- title: 'Dashboard',
- } as MatomoRouteData,
- },
- },
- {
- path: 'product',
- loadChildren: () =>
- import('@page/catalog').then((m) => m.PageCatalogModule),
- canActivate: [CanActivateProductGuard],
- },
- {
- path: ':processId/product',
- loadChildren: () =>
- import('@page/catalog').then((m) => m.PageCatalogModule),
- canActivate: [CanActivateProductWithProcessIdGuard],
- resolve: { processId: ProcessIdResolver },
- },
- {
- path: 'order',
- loadChildren: () =>
- import('@page/customer-order').then((m) => m.CustomerOrderModule),
- canActivate: [CanActivateCustomerOrdersGuard],
- },
- {
- path: ':processId/order',
- loadChildren: () =>
- import('@page/customer-order').then((m) => m.CustomerOrderModule),
- canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
- resolve: { processId: ProcessIdResolver },
- },
- {
- path: 'customer',
- loadChildren: () =>
- import('@page/customer').then((m) => m.CustomerModule),
- canActivate: [CanActivateCustomerGuard],
- },
- {
- path: ':processId/customer',
- loadChildren: () =>
- import('@page/customer').then((m) => m.CustomerModule),
- canActivate: [CanActivateCustomerWithProcessIdGuard],
- resolve: { processId: ProcessIdResolver },
- },
- {
- path: 'cart',
- loadChildren: () =>
- import('@page/checkout').then((m) => m.PageCheckoutModule),
- canActivate: [CanActivateCartGuard],
- },
- {
- path: ':processId/cart',
- loadChildren: () =>
- import('@page/checkout').then((m) => m.PageCheckoutModule),
- canActivate: [CanActivateCartWithProcessIdGuard],
- },
- {
- path: 'pickup-shelf',
- canActivate: [ProcessIdGuard],
- // NOTE: This is a workaround for the canActivate guard not being called
- loadChildren: () =>
- import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
- },
- {
- path: ':processId/pickup-shelf',
- canActivate: [ActivateProcessIdGuard],
- loadChildren: () =>
- import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
- },
- { path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
- ],
- resolve: { section: CustomerSectionResolver },
- },
- {
- path: 'filiale',
- component: MainComponent,
- children: [
- {
- path: 'task-calendar',
- loadChildren: () =>
- import('@page/task-calendar').then(
- (m) => m.PageTaskCalendarModule,
- ),
- canActivate: [CanActivateTaskCalendarGuard],
- },
- {
- path: 'pickup-shelf',
- canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
- // NOTE: This is a workaround for the canActivate guard not being called
- loadChildren: () =>
- import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
- },
- {
- path: 'goods/in',
- loadChildren: () =>
- import('@page/goods-in').then((m) => m.GoodsInModule),
- canActivate: [CanActivateGoodsInGuard],
- },
- {
- path: 'remission',
- loadChildren: () =>
- import('@page/remission').then((m) => m.PageRemissionModule),
- canActivate: [CanActivateRemissionGuard],
- },
- {
- path: 'package-inspection',
- loadChildren: () =>
- import('@page/package-inspection').then(
- (m) => m.PackageInspectionModule,
- ),
- canActivate: [CanActivatePackageInspectionGuard],
- },
- {
- path: 'assortment',
- loadChildren: () =>
- import('@page/assortment').then((m) => m.AssortmentModule),
- canActivate: [CanActivateAssortmentGuard],
- },
- { path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
- ],
- resolve: { section: BranchSectionResolver },
- },
- ],
- },
- {
- path: ':tabId',
- component: MainComponent,
- resolve: { process: tabResolverFn, tab: tabResolverFn },
- canActivate: [IsAuthenticatedGuard],
- children: [
- {
- path: 'return',
- loadChildren: () =>
- import('@isa/oms/feature/return-search').then((m) => m.routes),
- },
- {
- path: 'remission',
- loadChildren: () =>
- import('@isa/remission/feature/remission-list').then((m) => m.routes),
- },
- ],
- },
-];
-
-if (isDevMode()) {
- routes.unshift({
- path: 'preview',
- component: PreviewComponent,
- });
-}
-
-@NgModule({
- imports: [RouterModule.forRoot(routes), TokenLoginModule],
- exports: [RouterModule],
- providers: [provideScrollPositionRestoration()],
-})
-export class AppRoutingModule {}
+import { isDevMode, NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+import {
+ CanActivateCartGuard,
+ CanActivateCartWithProcessIdGuard,
+ CanActivateCustomerGuard,
+ CanActivateCustomerOrdersGuard,
+ CanActivateCustomerOrdersWithProcessIdGuard,
+ CanActivateCustomerWithProcessIdGuard,
+ CanActivateGoodsInGuard,
+ CanActivateProductGuard,
+ CanActivateProductWithProcessIdGuard,
+ CanActivateRemissionGuard,
+ CanActivateTaskCalendarGuard,
+ IsAuthenticatedGuard,
+} from './guards';
+import { CanActivateAssortmentGuard } from './guards/can-activate-assortment.guard';
+import { CanActivatePackageInspectionGuard } from './guards/can-activate-package-inspection.guard';
+import { MainComponent } from './main.component';
+import { PreviewComponent } from './preview';
+import {
+ BranchSectionResolver,
+ CustomerSectionResolver,
+ ProcessIdResolver,
+} from './resolvers';
+import { TokenLoginComponent, TokenLoginModule } from './token-login';
+import { ProcessIdGuard } from './guards/process-id.guard';
+import {
+ ActivateProcessIdGuard,
+ ActivateProcessIdWithConfigKeyGuard,
+} from './guards/activate-process-id.guard';
+import { MatomoRouteData } from 'ngx-matomo-client';
+import { tabResolverFn } from '@isa/core/tabs';
+import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
+
+const routes: Routes = [
+ { path: '', redirectTo: 'kunde/dashboard', pathMatch: 'full' },
+ {
+ path: 'login',
+ children: [
+ { path: ':token', component: TokenLoginComponent },
+ { path: '**', redirectTo: 'kunde', pathMatch: 'full' },
+ ],
+ },
+ {
+ path: '',
+ canActivate: [IsAuthenticatedGuard],
+ children: [
+ {
+ path: 'kunde',
+ component: MainComponent,
+ children: [
+ {
+ path: 'dashboard',
+ loadChildren: () =>
+ import('@page/dashboard').then((m) => m.DashboardModule),
+ data: {
+ matomo: {
+ title: 'Dashboard',
+ } as MatomoRouteData,
+ },
+ },
+ {
+ path: 'product',
+ loadChildren: () =>
+ import('@page/catalog').then((m) => m.PageCatalogModule),
+ canActivate: [CanActivateProductGuard],
+ },
+ {
+ path: ':processId/product',
+ loadChildren: () =>
+ import('@page/catalog').then((m) => m.PageCatalogModule),
+ canActivate: [CanActivateProductWithProcessIdGuard],
+ resolve: { processId: ProcessIdResolver },
+ },
+ {
+ path: 'order',
+ loadChildren: () =>
+ import('@page/customer-order').then((m) => m.CustomerOrderModule),
+ canActivate: [CanActivateCustomerOrdersGuard],
+ },
+ {
+ path: ':processId/order',
+ loadChildren: () =>
+ import('@page/customer-order').then((m) => m.CustomerOrderModule),
+ canActivate: [CanActivateCustomerOrdersWithProcessIdGuard],
+ resolve: { processId: ProcessIdResolver },
+ },
+ {
+ path: 'customer',
+ loadChildren: () =>
+ import('@page/customer').then((m) => m.CustomerModule),
+ canActivate: [CanActivateCustomerGuard],
+ },
+ {
+ path: ':processId/customer',
+ loadChildren: () =>
+ import('@page/customer').then((m) => m.CustomerModule),
+ canActivate: [CanActivateCustomerWithProcessIdGuard],
+ resolve: { processId: ProcessIdResolver },
+ },
+ {
+ path: 'cart',
+ loadChildren: () =>
+ import('@page/checkout').then((m) => m.PageCheckoutModule),
+ canActivate: [CanActivateCartGuard],
+ },
+ {
+ path: ':processId/cart',
+ loadChildren: () =>
+ import('@page/checkout').then((m) => m.PageCheckoutModule),
+ canActivate: [CanActivateCartWithProcessIdGuard],
+ },
+ {
+ path: 'pickup-shelf',
+ canActivate: [ProcessIdGuard],
+ // NOTE: This is a workaround for the canActivate guard not being called
+ loadChildren: () =>
+ import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
+ },
+ {
+ path: ':processId/pickup-shelf',
+ canActivate: [ActivateProcessIdGuard],
+ loadChildren: () =>
+ import('@page/pickup-shelf').then((m) => m.PickupShelfOutModule),
+ },
+ { path: '**', redirectTo: 'dashboard', pathMatch: 'full' },
+ ],
+ resolve: { section: CustomerSectionResolver },
+ },
+ {
+ path: 'filiale',
+ component: MainComponent,
+ children: [
+ {
+ path: 'task-calendar',
+ loadChildren: () =>
+ import('@page/task-calendar').then(
+ (m) => m.PageTaskCalendarModule,
+ ),
+ canActivate: [CanActivateTaskCalendarGuard],
+ },
+ {
+ path: 'pickup-shelf',
+ canActivate: [ActivateProcessIdWithConfigKeyGuard('pickupShelf')],
+ // NOTE: This is a workaround for the canActivate guard not being called
+ loadChildren: () =>
+ import('@page/pickup-shelf').then((m) => m.PickupShelfInModule),
+ },
+ {
+ path: 'goods/in',
+ loadChildren: () =>
+ import('@page/goods-in').then((m) => m.GoodsInModule),
+ canActivate: [CanActivateGoodsInGuard],
+ },
+ {
+ path: 'remission',
+ loadChildren: () =>
+ import('@page/remission').then((m) => m.PageRemissionModule),
+ canActivate: [CanActivateRemissionGuard],
+ },
+ {
+ path: 'package-inspection',
+ loadChildren: () =>
+ import('@page/package-inspection').then(
+ (m) => m.PackageInspectionModule,
+ ),
+ canActivate: [CanActivatePackageInspectionGuard],
+ },
+ {
+ path: 'assortment',
+ loadChildren: () =>
+ import('@page/assortment').then((m) => m.AssortmentModule),
+ canActivate: [CanActivateAssortmentGuard],
+ },
+ { path: '**', redirectTo: 'task-calendar', pathMatch: 'full' },
+ ],
+ resolve: { section: BranchSectionResolver },
+ },
+ ],
+ },
+ {
+ path: ':tabId',
+ component: MainComponent,
+ resolve: { process: tabResolverFn, tab: tabResolverFn },
+ canActivate: [IsAuthenticatedGuard],
+ children: [
+ {
+ path: 'return',
+ loadChildren: () =>
+ import('@isa/oms/feature/return-search').then((m) => m.routes),
+ },
+ {
+ path: 'remission',
+ children: [
+ {
+ path: 'return-receipt',
+ loadChildren: () =>
+ import(
+ '@isa/remission/feature/remission-return-receipt-list'
+ ).then((m) => m.routes),
+ },
+ {
+ path: '',
+ loadChildren: () =>
+ import('@isa/remission/feature/remission-list').then(
+ (m) => m.routes,
+ ),
+ },
+ ],
+ },
+ ],
+ },
+];
+
+if (isDevMode()) {
+ routes.unshift({
+ path: 'preview',
+ component: PreviewComponent,
+ });
+}
+
+@NgModule({
+ imports: [
+ RouterModule.forRoot(routes, { bindToComponentInputs: true }),
+ TokenLoginModule,
+ ],
+ exports: [RouterModule],
+ providers: [provideScrollPositionRestoration()],
+})
+export class AppRoutingModule {}
diff --git a/apps/isa-app/src/shared/shell/side-menu/side-menu.component.html b/apps/isa-app/src/shared/shell/side-menu/side-menu.component.html
index 15b83bc4e..ffc7e67d9 100644
--- a/apps/isa-app/src/shared/shell/side-menu/side-menu.component.html
+++ b/apps/isa-app/src/shared/shell/side-menu/side-menu.component.html
@@ -1,303 +1,319 @@
-
-
-
+
+
+
diff --git a/apps/isa-app/src/tailwind.scss b/apps/isa-app/src/tailwind.scss
index 9e107da5b..ba829568c 100644
--- a/apps/isa-app/src/tailwind.scss
+++ b/apps/isa-app/src/tailwind.scss
@@ -9,6 +9,7 @@
@layer components {
@import "../../../libs/ui/buttons/src/buttons.scss";
+ @import "../../../libs/ui/bullet-list/src/bullet-list.scss";
@import "../../../libs/ui/datepicker/src/datepicker.scss";
@import "../../../libs/ui/dialog/src/dialog.scss";
@import "../../../libs/ui/input-controls/src/input-controls.scss";
diff --git a/docs/guidelines/testing.md b/docs/guidelines/testing.md
index 2993d5c01..56f0c856a 100644
--- a/docs/guidelines/testing.md
+++ b/docs/guidelines/testing.md
@@ -10,7 +10,20 @@
* [Clarity and Isolation](#clarity-and-isolation)
3. [Testing Tools](#testing-tools)
* [Jest: The Test Runner](#jest-the-test-runner)
- * [Spectator: Simplifying Angular Tests](#spectator-simplifying-angular-tests)
+ * [Vitest: Modern Testing Framework](#vitest-modern-testing-framework)
+ * [Overview](#vitest-overview)
+ * [Configuration](#vitest-configuration)
+ * [Core Testing Features](#core-testing-features)
+ * [Mocking in Vitest](#mocking-in-vitest)
+ * [Example Test Structures with Vitest](#example-test-structures-with-vitest)
+ * [Angular Testing Utilities: Official Framework](#angular-testing-utilities-official-framework)
+ * [Overview](#angular-testing-utilities-overview)
+ * [TestBed Configuration](#testbed-configuration)
+ * [Component Testing](#angular-component-testing)
+ * [Service Testing](#angular-service-testing)
+ * [HTTP Testing](#angular-http-testing)
+ * [Mocking with Angular](#mocking-with-angular)
+ * [Spectator: Simplifying Angular Tests (Legacy)](#spectator-simplifying-angular-tests-legacy)
* [Overview](#spectator-overview)
* [Core Factory Methods](#core-factory-methods)
* [Querying Elements](#querying-elements)
@@ -21,7 +34,7 @@
* [Overview](#ng-mocks-overview)
* [Key APIs](#key-apis)
* [When to Use ng-mocks](#when-to-use-ng-mocks)
- * [Integration with Spectator](#integration-with-spectator)
+ * [Integration with Angular Testing Utilities](#integration-with-angular-testing-utilities)
4. [Best Practices](#best-practices)
* [General](#general)
* [Component Testing](#component-testing)
@@ -31,18 +44,9 @@
* [Async Operations](#async-operations)
* [Performance](#performance)
* [Debugging](#debugging)
-5. [Example Test Structures](#example-test-structures)
- * [Basic Component Test (Spectator)](#basic-component-test-spectator)
- * [Host Component Test (Spectator + Overrides)](#host-component-test-spectator--overrides)
- * [Service Test (Spectator)](#service-test-spectator)
- * [HTTP Service Test (Spectator)](#http-service-test-spectator)
- * [Directive Test (Spectator)](#directive-test-spectator)
- * [Standalone Component Test (Spectator)](#standalone-component-test-spectator)
- * [Deferrable Views Test (Spectator)](#deferrable-views-test-spectator)
- * [ng-mocks Example](#ng-mocks-example)
-6. [End-to-End (E2E) Testing Attributes (`data-what`, `data-which`)](#end-to-end-e2e-testing-attributes-data-what-data-which)
-7. [Running Tests (Nx)](#running-tests-nx)
-8. [References](#references)
+5. [End-to-End (E2E) Testing Attributes (`data-what`, `data-which`)](#end-to-end-e2e-testing-attributes-data-what-data-which)
+6. [Running Tests (Nx)](#running-tests-nx)
+7. [References](#references)
---
@@ -53,8 +57,8 @@ This document outlines the guidelines and best practices for writing unit tests
**Key Requirements:**
- Test files must end with `.spec.ts`.
-- Use **Jest** as the primary test runner.
-- Utilize **Spectator** for testing Components, Directives, and Services.
+- **Migration to Vitest**: New libraries should use **Vitest** as the primary test runner. Existing libraries continue to use **Jest** until migrated.
+- **Testing Framework Migration**: New tests should use **Angular Testing Utilities** (TestBed, ComponentFixture, etc.). Existing tests using **Spectator** remain until migrated.
- Employ **ng-mocks** for mocking complex dependencies like child components.
---
@@ -97,11 +101,390 @@ Go beyond the "happy path". Ensure your tests cover:
### Jest: The Test Runner
-[Jest](https://jestjs.io/docs/getting-started) is the testing framework used for running tests, providing features like test discovery, assertion functions (`expect`), and mocking capabilities (`jest.fn()`, `jest.spyOn()`).
+[Jest](https://jestjs.io/docs/getting-started) is the testing framework currently used for existing libraries, providing features like test discovery, assertion functions (`expect`), and mocking capabilities (`jest.fn()`, `jest.spyOn()`). **Note**: New libraries should use Vitest instead of Jest.
-### Spectator: Simplifying Angular Tests
+### Vitest: Modern Testing Framework
-[Spectator](https://ngneat.github.io/spectator/) significantly reduces boilerplate and simplifies testing Angular components, directives, and services.
+[Vitest](https://vitest.dev/) is the modern testing framework being adopted for new libraries. It provides fast execution, native ES modules support, and excellent developer experience.
+
+#### Vitest Overview
+
+- **Fast Execution**: Powered by Vite, offering significantly faster test runs than Jest
+- **Native ES Modules**: Built-in support for ES modules without complex configuration
+- **Jest Compatibility**: Compatible with Jest's API, making migration straightforward
+- **Hot Module Replacement**: Tests can run in watch mode with instant feedback
+- **TypeScript Support**: First-class TypeScript support out of the box
+- **Built-in Coverage**: Integrated code coverage reporting with c8
+
+#### Vitest Configuration
+
+Vitest configuration is typically done in `vitest.config.ts` or within the Nx project configuration:
+
+```typescript
+// vitest.config.ts
+import { defineConfig } from 'vitest/config';
+import angular from '@analogjs/vite-plugin-angular';
+
+export default defineConfig({
+ plugins: [angular()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['src/test-setup.ts'],
+ include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ reporters: ['default'],
+ coverage: {
+ reporter: ['text', 'json', 'html'],
+ exclude: [
+ 'node_modules/',
+ 'src/test-setup.ts',
+ ],
+ },
+ },
+});
+```
+
+#### Core Testing Features
+
+Vitest provides similar APIs to Jest with enhanced performance:
+
+```typescript
+// Basic test structure
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+describe('MyService', () => {
+ beforeEach(() => {
+ // Setup logic
+ });
+
+ it('should perform expected behavior', () => {
+ // Test implementation
+ expect(result).toBe(expected);
+ });
+});
+```
+
+#### Mocking in Vitest
+
+Vitest provides powerful mocking capabilities:
+
+```typescript
+import { vi } from 'vitest';
+
+// Mock functions
+const mockFn = vi.fn();
+const mockFnWithReturn = vi.fn().mockReturnValue('mocked value');
+
+// Mock modules
+vi.mock('./my-module', () => ({
+ MyClass: vi.fn(),
+ myFunction: vi.fn(),
+}));
+
+// Spy on existing functions
+const spy = vi.spyOn(object, 'method');
+```
+
+#### Example Test Structures with Vitest
+
+**Basic Service Test with Vitest:**
+
+```typescript
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { MyService } from './my-service.service';
+import { HttpClient } from '@angular/common/http';
+import { of } from 'rxjs';
+
+describe('MyService', () => {
+ let service: MyService;
+ let httpClientSpy: any;
+
+ beforeEach(() => {
+ httpClientSpy = {
+ get: vi.fn(),
+ post: vi.fn(),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ MyService,
+ { provide: HttpClient, useValue: httpClientSpy },
+ ],
+ });
+
+ service = TestBed.inject(MyService);
+ });
+
+ it('should fetch data successfully', async () => {
+ // Arrange
+ const mockData = { id: 1, name: 'Test' };
+ httpClientSpy.get.mockReturnValue(of(mockData));
+
+ // Act
+ const result = await service.getData().toPromise();
+
+ // Assert
+ expect(result).toEqual(mockData);
+ expect(httpClientSpy.get).toHaveBeenCalledWith('/api/data');
+ });
+});
+```
+
+**Angular Testing Utilities with Vitest:**
+
+For new tests, use Angular's official testing utilities with Vitest:
+
+```typescript
+import { describe, it, expect, beforeEach } from 'vitest';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MyComponent } from './my-component.component';
+
+describe('MyComponent', () => {
+ let component: MyComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [MyComponent], // For standalone components
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MyComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
+```
+
+### Angular Testing Utilities: Official Framework
+
+[Angular Testing Utilities](https://angular.dev/guide/testing) are the official testing tools provided by the Angular framework. **New tests should use these utilities instead of Spectator.**
+
+#### Angular Testing Utilities Overview
+
+- **Official Support**: Maintained by the Angular team, ensuring compatibility with Angular updates
+- **Comprehensive API**: Full access to Angular's testing capabilities without abstractions
+- **TestBed Integration**: Direct integration with Angular's testing module for dependency injection
+- **Type Safety**: Full TypeScript support with proper type inference
+- **Standard Approach**: Aligns with Angular documentation and community best practices
+
+#### TestBed Configuration
+
+TestBed is the primary API for configuring and creating Angular testing modules:
+
+```typescript
+import { TestBed } from '@angular/core/testing';
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-test',
+ template: '{{ title }}
',
+ standalone: true,
+})
+class TestComponent {
+ title = 'Test Title';
+}
+
+describe('TestComponent', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestComponent], // For standalone components
+ // providers: [], // Add service providers
+ // declarations: [], // For non-standalone components (legacy)
+ }).compileComponents();
+ });
+});
+```
+
+#### Angular Component Testing
+
+Testing components with Angular Testing Utilities:
+
+```typescript
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { DebugElement } from '@angular/core';
+import { MyComponent } from './my-component.component';
+
+describe('MyComponent', () => {
+ let component: MyComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [MyComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(MyComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should display title', () => {
+ // Arrange
+ const expectedTitle = 'Test Title';
+ component.title = expectedTitle;
+
+ // Act
+ fixture.detectChanges();
+
+ // Assert
+ const titleElement: HTMLElement = fixture.nativeElement.querySelector('h1');
+ expect(titleElement.textContent).toContain(expectedTitle);
+ });
+
+ it('should emit event on button click', () => {
+ // Arrange
+ spyOn(component.buttonClicked, 'emit');
+ const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
+
+ // Act
+ button.click();
+
+ // Assert
+ expect(component.buttonClicked.emit).toHaveBeenCalled();
+ });
+
+ it('should find element by directive', () => {
+ // Using DebugElement for more advanced querying
+ const debugElement: DebugElement = fixture.debugElement;
+ const buttonDebugElement = debugElement.query(By.css('button'));
+
+ expect(buttonDebugElement).toBeTruthy();
+ });
+});
+```
+
+#### Angular Service Testing
+
+Testing services with dependency injection:
+
+```typescript
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { UserService } from './user.service';
+import { User } from './user.model';
+
+describe('UserService', () => {
+ let service: UserService;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [UserService],
+ });
+
+ service = TestBed.inject(UserService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify(); // Verify no outstanding HTTP requests
+ });
+
+ it('should fetch users', () => {
+ // Arrange
+ const mockUsers: User[] = [
+ { id: 1, name: 'User 1' },
+ { id: 2, name: 'User 2' },
+ ];
+
+ // Act
+ service.getUsers().subscribe(users => {
+ // Assert
+ expect(users).toEqual(mockUsers);
+ });
+
+ // Assert HTTP request
+ const req = httpMock.expectOne('/api/users');
+ expect(req.request.method).toBe('GET');
+ req.flush(mockUsers);
+ });
+});
+```
+
+#### Angular HTTP Testing
+
+Using HttpClientTestingModule for HTTP testing:
+
+```typescript
+import { TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { ApiService } from './api.service';
+
+describe('ApiService', () => {
+ let service: ApiService;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [ApiService],
+ });
+
+ service = TestBed.inject(ApiService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ it('should handle HTTP errors', () => {
+ const errorMessage = 'Server error';
+
+ service.getData().subscribe({
+ next: () => fail('Should have failed'),
+ error: (error) => {
+ expect(error.status).toBe(500);
+ expect(error.statusText).toBe('Server Error');
+ },
+ });
+
+ const req = httpMock.expectOne('/api/data');
+ req.flush(errorMessage, { status: 500, statusText: 'Server Error' });
+ });
+});
+```
+
+#### Mocking with Angular
+
+Angular provides several ways to mock dependencies:
+
+```typescript
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+
+// Mock service implementation
+const mockUserService = {
+ getUsers: jasmine.createSpy('getUsers').and.returnValue(of([])),
+ createUser: jasmine.createSpy('createUser').and.returnValue(of({})),
+};
+
+describe('ComponentWithDependency', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ComponentWithDependency],
+ providers: [
+ { provide: UserService, useValue: mockUserService },
+ ],
+ }).compileComponents();
+ });
+
+ it('should call user service', () => {
+ const fixture = TestBed.createComponent(ComponentWithDependency);
+ const component = fixture.componentInstance;
+
+ component.loadUsers();
+
+ expect(mockUserService.getUsers).toHaveBeenCalled();
+ });
+});
+```
+
+### Spectator: Simplifying Angular Tests (Legacy)
+
+[Spectator](https://ngneat.github.io/spectator/) significantly reduces boilerplate and simplifies testing Angular components, directives, and services. **Note: This is legacy for existing tests. New tests should use Angular Testing Utilities.**
#### Spectator Overview
@@ -217,9 +600,36 @@ expect('.parent').toHaveDescendant('.child');
- Testing components that rely heavily on content projection (`ng-content`).
- **Caution:** Avoid `MockComponent` and `MockDirective` for standalone components/directives; use Spectator's `overrideComponents` instead.
-#### Integration with Spectator
+#### Integration with Angular Testing Utilities
-Spectator and ng-mocks can work together. Use Spectator for the primary test setup and interaction/assertion. Use Spectator's `overrideComponents` to replace standalone child components/directives. Use `ng-mocks`' `MockProvider` or `MockInstance` when needed for services or advanced mocking scenarios.
+ng-mocks integrates well with Angular Testing Utilities. Use Angular's TestBed for the primary test setup and ng-mocks for advanced mocking scenarios:
+
+```typescript
+import { TestBed } from '@angular/core/testing';
+import { MockBuilder, MockRender, MockProvider } from 'ng-mocks';
+import { ParentComponent } from './parent.component';
+import { ChildService } from './child.service';
+
+describe('ParentComponent with ng-mocks', () => {
+ beforeEach(() => {
+ return MockBuilder(ParentComponent)
+ .provide(MockProvider(ChildService, {
+ getData: () => of(['mocked data']),
+ }));
+ });
+
+ it('should render with mocked service', () => {
+ const fixture = MockRender(ParentComponent);
+ const component = fixture.point.componentInstance;
+
+ expect(component).toBeTruthy();
+ });
+});
+```
+
+#### Integration with Spectator (Legacy)
+
+Spectator and ng-mocks can work together for existing tests. Use Spectator for the primary test setup and interaction/assertion. Use Spectator's `overrideComponents` to replace standalone child components/directives. Use `ng-mocks`' `MockProvider` or `MockInstance` when needed for services or advanced mocking scenarios.
```typescript
// Example using Spectator's overrideComponents for a standalone child
@@ -309,448 +719,6 @@ const createComponent = createComponentFactory({
---
-## Example Test Structures
-
-*(Note: Examples are illustrative and may need adaptation)*
-
-### Basic Component Test (Spectator)
-
-```typescript
-import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
-import { MyComponent } from './my-component.component';
-
-describe('MyComponent', () => {
- let spectator: Spectator;
- const createComponent = createComponentFactory(MyComponent);
-
- beforeEach(() => {
- spectator = createComponent();
- });
-
- it('should create', () => {
- // Assert
- expect(spectator.component).toBeTruthy();
- });
-
- it('should display the title correctly', () => {
- // Arrange
- const testTitle = 'Test Title';
- spectator.setInput('title', testTitle); // Assuming an @Input() title
- spectator.detectChanges(); // Trigger change detection
-
- // Act
- const titleElement = spectator.query('h1');
-
- // Assert
- expect(titleElement).toHaveText(testTitle);
- });
-
- it('should emit output event on button click', () => {
- // Arrange
- const outputSpy = jest.fn();
- spectator.output('actionClicked').subscribe(outputSpy); // Assuming an @Output() actionClicked
-
- // Act
- spectator.click('button.action-button');
-
- // Assert
- expect(outputSpy).toHaveBeenCalledTimes(1);
- expect(outputSpy).toHaveBeenCalledWith(/* expected payload */);
- });
-});
-```
-
-### Host Component Test (Spectator + Overrides)
-
-```typescript
-import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest';
-import { ParentComponent } from './parent.component'; // Assumed standalone, imports ChildComponent
-import { ChildComponent } from './child.component'; // Assumed standalone
-import { MockChildComponent } from './mock-child.component'; // Assumed standalone mock
-
-describe('ParentComponent in Host with Overrides', () => {
- let spectator: SpectatorHost;
-
- // Override the standalone child component
- const createHost = createHostFactory({
- component: ParentComponent,
- overrideComponents: [
- [
- ParentComponent,
- {
- remove: { imports: [ChildComponent] },
- add: { imports: [MockChildComponent] },
- },
- ],
- ],
- template: `
-
- `,
- });
-
- const mockOutputHandler = jest.fn();
-
- beforeEach(() => {
- spectator = createHost(undefined, {
- hostProps: {
- hostInputData: { id: 1, value: 'Test Data' },
- hostHandleOutput: mockOutputHandler,
- },
- });
- });
-
- it('should render the parent component', () => {
- // Assert
- expect(spectator.component).toBeTruthy();
- });
-
- it('should render the mocked child component', () => {
- // Arrange
- const mockedChild = spectator.query(MockChildComponent); // Query the mocked child
-
- // Assert
- expect(mockedChild).toBeTruthy();
- // You might check properties/methods on the mock instance if needed
- // expect(mockedChild?.someMockProperty).toBe(...)
- });
-
- it('should handle output event from the component', () => {
- // Arrange
- const payload = { success: true };
- // Assume ParentComponent emits outputEvent when something happens.
- spectator.component.outputEvent.emit(payload);
-
- // Assert
- expect(mockOutputHandler).toHaveBeenCalledWith(payload);
- });
-});
-```
-
-### Service Test (Spectator)
-
-```typescript
-import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
-import { DataService } from './data.service';
-import { ApiService } from './api.service';
-import { of } from 'rxjs';
-
-describe('DataService', () => {
- let spectator: SpectatorService;
- let apiServiceMock: ApiService;
-
- const createService = createServiceFactory({
- service: DataService,
- mocks: [ApiService], // Mock the dependency
- });
-
- beforeEach(() => {
- spectator = createService();
- // Get the mocked instance provided by Spectator
- apiServiceMock = spectator.inject(ApiService);
- });
-
- it('should be created', () => {
- // Assert
- expect(spectator.service).toBeTruthy();
- });
-
- it('should fetch data using ApiService', (done) => {
- // Arrange
- const mockData = [{ id: 1, name: 'Test Item' }];
- // Configure the mock method
- apiServiceMock.fetchItems.mockReturnValue(of(mockData));
-
- // Act
- spectator.service.getItems().subscribe(items => {
- // Assert
- expect(items).toEqual(mockData);
- expect(apiServiceMock.fetchItems).toHaveBeenCalledTimes(1);
- done(); // Signal async test completion
- });
- });
-});
-```
-
-### HTTP Service Test (Spectator)
-
-```typescript
-import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator/jest';
-import { UserHttpService } from './user-http.service';
-import { AuthService } from './auth.service'; // Assume this provides a token
-
-describe('UserHttpService', () => {
- let spectator: SpectatorHttp;
- let authServiceMock: AuthService;
-
- const createHttp = createHttpFactory({
- service: UserHttpService,
- mocks: [AuthService], // Mock dependencies
- });
-
- beforeEach(() => {
- spectator = createHttp();
- authServiceMock = spectator.inject(AuthService);
- // Setup mock return value for token
- authServiceMock.getToken.mockReturnValue('fake-token-123');
- });
-
- it('should fetch users from the correct endpoint with GET', () => {
- // Arrange
- const mockUsers = [{ id: 1, name: 'User 1' }];
-
- // Act
- spectator.service.getUsers().subscribe(); // Call the method
-
- // Assert
- // Expect one request to the URL with the specified method
- const req = spectator.expectOne('/api/users', HttpMethod.GET);
- // Optionally check headers
- expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token-123');
- // Respond to the request to complete the observable
- req.flush(mockUsers);
- });
-
- it('should send user data to the correct endpoint with POST', () => {
- // Arrange
- const newUser = { name: 'New User', email: 'new@test.com' };
- const createdUser = { id: 2, ...newUser };
-
- // Act
- spectator.service.createUser(newUser).subscribe();
-
- // Assert
- const req = spectator.expectOne('/api/users', HttpMethod.POST);
- // Check request body
- expect(req.request.body).toEqual(newUser);
- // Check headers
- expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token-123');
- // Respond
- req.flush(createdUser);
- });
-});
-```
-
-### Directive Test (Spectator)
-
-```typescript
-import { createDirectiveFactory, SpectatorDirective } from '@ngneat/spectator/jest';
-import { HighlightDirective } from './highlight.directive'; // Assumed standalone
-// If HighlightDirective imported another standalone directive/component:
-// import { SomeDependencyDirective } from './some-dependency.directive';
-// import { MockSomeDependencyDirective } from './mock-some-dependency.directive';
-
-describe('HighlightDirective', () => {
- let spectator: SpectatorDirective;
-
- const createDirective = createDirectiveFactory({
- directive: HighlightDirective,
- // Example if the directive had standalone dependencies to override:
- // overrideDirectives: [
- // [
- // HighlightDirective,
- // {
- // remove: { imports: [SomeDependencyDirective] },
- // add: { imports: [MockSomeDependencyDirective] }
- // }
- // ]
- // ],
- template: `Test Content
`,
- });
-
- beforeEach(() => spectator = createDirective());
-
- it('should apply the initial highlight color from input', () => {
- // Assert
- expect(spectator.element).toHaveStyle({ backgroundColor: 'yellow' });
- });
-
- it('should change style on mouseenter', () => {
- // Act
- spectator.dispatchMouseEvent(spectator.element, 'mouseenter');
- spectator.detectChanges();
-
- // Assert
- expect(spectator.element).toHaveStyle({
- backgroundColor: 'yellow', // Or expected mouseenter color
- fontWeight: 'bold', // Example style change
- });
- });
-
- it('should revert style on mouseleave', () => {
- // Arrange
- spectator.dispatchMouseEvent(spectator.element, 'mouseenter');
- spectator.detectChanges();
-
- // Act
- spectator.dispatchMouseEvent(spectator.element, 'mouseleave');
- spectator.detectChanges();
-
- // Assert
- expect(spectator.element).toHaveStyle({
- backgroundColor: 'yellow', // Back to initial or default
- fontWeight: 'normal', // Reverted style
- });
- });
-});
-```
-
-### Standalone Component Test (Spectator)
-
-Testing standalone components is very similar to regular components. Spectator handles the necessary setup.
-
-```typescript
-import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
-import { StandaloneButtonComponent } from './standalone-button.component';
-// No need to import CommonModule etc. if they are in the component's imports array
-
-describe('StandaloneButtonComponent', () => {
- let spectator: Spectator;
-
- // Factory setup is the same
- const createComponent = createComponentFactory({
- component: StandaloneButtonComponent,
- // No 'imports' needed here if the component imports them itself
- // Mocks can still be provided if needed
- // mocks: [SomeService]
- });
-
- beforeEach(() => {
- spectator = createComponent();
- });
-
- it('should create', () => {
- // Assert
- expect(spectator.component).toBeTruthy();
- });
-
- it('should display label', () => {
- // Arrange
- spectator.setInput('label', 'Click Me');
- spectator.detectChanges();
-
- // Assert
- expect(spectator.query('button')).toHaveText('Click Me');
- });
-});
-```
-
-### Deferrable Views Test (Spectator)
-
-Spectator provides helpers to control the state of `@defer` blocks.
-
-```typescript
-import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
-import { DeferredComponent } from './deferred.component'; // Assume this uses @defer
-
-describe('DeferredComponent', () => {
- let spectator: Spectator;
-
- const createComponent = createComponentFactory({
- component: DeferredComponent,
- });
-
- beforeEach(() => {
- spectator = createComponent();
- });
-
- it('should initially show placeholder content', () => {
- // Act: Render the placeholder state (often the default)
- spectator.deferBlock().renderPlaceholder(); // Or renderIdle()
-
- // Assert
- expect(spectator.query('.placeholder-content')).toExist();
- expect(spectator.query('.deferred-content')).not.toExist();
- expect(spectator.query('.loading-indicator')).not.toExist();
- });
-
- it('should show loading state', () => {
- // Act: Render the loading state
- spectator.deferBlock().renderLoading();
-
- // Assert
- expect(spectator.query('.loading-indicator')).toExist();
- expect(spectator.query('.placeholder-content')).not.toExist();
- expect(spectator.query('.deferred-content')).not.toExist();
- });
-
- it('should render deferred content when completed', () => {
- // Act: Render the completed state
- spectator.deferBlock().renderComplete();
-
- // Assert
- expect(spectator.query('.deferred-content')).toExist();
- expect(spectator.query('.placeholder-content')).not.toExist();
- expect(spectator.query('.loading-indicator')).not.toExist();
- });
-
- it('should handle multiple defer blocks by index', () => {
- // Act: Render the second defer block (index 1)
- spectator.deferBlock(1).renderComplete();
-
- // Assert
- expect(spectator.query('.second-deferred-content')).toExist();
- });
-});
-```
-
-### ng-mocks Example
-
-Illustrates using `MockBuilder` and `MockRender`. **Note:** This example uses `MockBuilder` which might be less common with Spectator and standalone components, but `MockInstance` and `MockProvider` remain useful. Prefer Spectator's factories and `overrideComponents` where possible.
-
-```typescript
-import { MockBuilder, MockInstance, MockRender, ngMocks, MockProvider } from 'ng-mocks';
-import { ProfileComponent } from './profile.component'; // Assume standalone
-import { AuthService } from './auth.service';
-import { ReactiveFormsModule } from '@angular/forms'; // Needed if ProfileComponent imports it
-
-describe('ProfileComponent (with ng-mocks helpers)', () => {
- // Reset customizations after each test
- MockInstance.scope();
-
- // Configure TestBed using MockBuilder - less common with Spectator, but possible
- beforeEach(() => {
- // Keep ProfileComponent real, mock AuthService, keep ReactiveFormsModule if needed
- return MockBuilder(ProfileComponent)
- .keep(ReactiveFormsModule) // Keep if ProfileComponent imports it
- .provide(MockProvider(AuthService)); // Use MockProvider for the service
- });
-
- it('should display user email from AuthService', () => {
- // Arrange: Configure the mock instance *before* rendering
- const mockEmail = 'test@example.com';
- // Use MockInstance to configure the globally mocked AuthService
- MockInstance(AuthService, 'getUserEmail', jest.fn().mockReturnValue(mockEmail));
-
- // Act: Render the component using MockRender
- const fixture = MockRender(ProfileComponent);
-
- // Assert: Use ngMocks helpers or fixture directly
- const emailDisplay = ngMocks.find(fixture, '.user-email');
- expect(emailDisplay.nativeElement.textContent).toContain(mockEmail);
- // Verify the mock was called (optional)
- expect(MockInstance(AuthService).getUserEmail).toHaveBeenCalled();
- });
-
- it('should call AuthService.updateProfile on form submit', () => {
- // Arrange
- // Setup spy on the globally mocked AuthService instance
- const updateSpy = MockInstance(AuthService, 'updateProfile', jest.fn());
- const fixture = MockRender(ProfileComponent);
-
- // Act: Simulate form input and submission
- ngMocks.change(ngMocks.find(fixture, 'input[name="firstName"]'), 'NewName');
- ngMocks.click(ngMocks.find(fixture, 'button[type="submit"]'));
-
- // Assert
- expect(updateSpy).toHaveBeenCalledWith(expect.objectContaining({ firstName: 'NewName' }));
- });
-});
-```
-
----
-
## End-to-End (E2E) Testing Attributes (`data-what`, `data-which`)
In end-to-end (E2E) testing of web applications, `data-what` and `data-which` are custom HTML data attributes used to enhance testability. They help identify elements or components in the DOM for testing purposes, making it easier for testing frameworks (like Selenium, Cypress, or WebdriverIO) to locate and interact with specific UI elements.
@@ -840,41 +808,75 @@ While `data-what` and `data-which` are not standardized HTML attributes, their u
## Running Tests (Nx)
-Use the Nx CLI to run tests efficiently within the monorepo.
+Use the Nx CLI to run tests efficiently within the monorepo. The commands differ depending on whether the project uses Jest or Vitest.
### Run Tests for a Specific Project
+**For Jest-based projects (existing libraries):**
```bash
npx nx test
# Example: npx nx test core-logging
# Example: npx nx test isa-app
```
+**For Vitest-based projects (new libraries):**
+```bash
+npx nx test
+# Nx automatically detects and uses the appropriate test runner
+# Example: npx nx test new-feature-lib
+```
+
### Run Tests in Watch Mode
-For active development, run tests continuously as files change:
-
+**Jest projects:**
```bash
npx nx test --watch
```
+**Vitest projects:**
+```bash
+npx nx test --watch
+# Or use Vitest's native watch mode
+npx vitest --watch
+```
+
### Run Tests with Coverage Report
-Generate a code coverage report:
-
+**Jest projects:**
```bash
npx nx test --code-coverage
```
+
+**Vitest projects:**
+```bash
+npx nx test --coverage
+# Or use Vitest directly
+npx vitest --coverage
+```
*(Coverage reports are typically generated in the `coverage/` directory)*
### Run a Specific Test File
-Target a single test file:
-
+**Jest projects:**
```bash
npx nx test --test-file=libs/core/utils/src/lib/my-util.spec.ts
```
+**Vitest projects:**
+```bash
+npx nx test --test-file=libs/new-feature/src/lib/my-util.spec.ts
+# Or use Vitest directly
+npx vitest src/lib/my-util.spec.ts
+```
+
+### Run Tests with UI (Vitest only)
+
+Vitest provides an interactive UI for test exploration:
+
+```bash
+npx vitest --ui
+```
+
### Run Affected Tests
Optimize CI/CD by running tests only for projects affected by your code changes:
@@ -887,11 +889,32 @@ npx nx affected:test --base=main --head=HEAD
npx nx affected:test
```
+### Migration Notes
+
+#### Test Runner Migration
+- **Existing libraries** continue using Jest until explicitly migrated
+- **New libraries** should be configured with Vitest from the start
+- Both test runners can coexist in the same monorepo
+- Nx handles the appropriate test runner selection automatically based on project configuration
+
+#### Testing Framework Migration
+- **New tests** should use Angular Testing Utilities (TestBed, ComponentFixture, etc.)
+- **Existing Spectator tests** remain until explicitly migrated
+- Both testing approaches can coexist in the same codebase
+- Angular Testing Utilities provide better long-term maintainability and align with Angular's official documentation
+
+#### Benefits of the New Approach
+- **Official Support**: Angular Testing Utilities are maintained by the Angular team
+- **Better Integration**: Direct integration with Angular's dependency injection and testing modules
+- **Improved Performance**: Vitest provides significantly faster test execution than Jest
+- **Future-Proof**: Aligns with Angular's testing direction and community best practices
+
---
## References
- **[Jest Documentation](https://jestjs.io/docs/getting-started)**: Official Jest documentation.
+- **[Vitest Documentation](https://vitest.dev/)**: Official Vitest documentation.
- **[Spectator Documentation](https://ngneat.github.io/spectator/)**: Official Spectator documentation.
- **[ng-mocks Documentation](https://ng-mocks.sudo.eu/)**: Official ng-mocks documentation.
- **[Angular Testing Guide](https://angular.dev/guide/testing)**: Official Angular testing concepts.
diff --git a/libs/common/decorators/README.md b/libs/common/decorators/README.md
new file mode 100644
index 000000000..f82e09f57
--- /dev/null
+++ b/libs/common/decorators/README.md
@@ -0,0 +1,277 @@
+# Common Decorators Library
+
+A collection of TypeScript decorators for common cross-cutting concerns in Angular applications.
+
+## Installation
+
+This library is already configured in the project's `tsconfig.base.json`. Import decorators using:
+
+```typescript
+import { InFlight, InFlightWithKey, InFlightWithCache } from '@isa/common/decorators';
+```
+
+## Available Decorators
+
+### 🚀 InFlight Decorators
+
+Prevent multiple simultaneous calls to the same async method. All concurrent calls receive the same Promise result.
+
+#### Basic Usage
+
+```typescript
+import { InFlight } from '@isa/common/decorators';
+
+@Injectable()
+class DataService {
+ @InFlight()
+ async fetchData(): Promise {
+ // Even if called multiple times simultaneously,
+ // only one API call will be made
+ return await this.http.get('/api/data').toPromise();
+ }
+}
+```
+
+**Benefits:**
+- Prevents duplicate API calls
+- Reduces server load
+- Improves application performance
+- All callers receive the same result
+
+### 🔑 InFlightWithKey
+
+Prevents duplicate calls while considering method arguments. Each unique set of arguments gets its own in-flight tracking.
+
+```typescript
+import { InFlightWithKey } from '@isa/common/decorators';
+
+@Injectable()
+class UserService {
+ @InFlightWithKey({
+ keyGenerator: (userId: string) => userId
+ })
+ async fetchUser(userId: string): Promise {
+ // Multiple calls with same userId share the same request
+ // Different userIds can execute simultaneously
+ return await this.http.get(`/api/users/${userId}`).toPromise();
+ }
+
+ @InFlightWithKey() // Uses JSON.stringify by default
+ async searchUsers(query: string, page: number): Promise {
+ return await this.http.get(`/api/users/search`, {
+ params: { query, page: page.toString() }
+ }).toPromise();
+ }
+}
+```
+
+**Configuration Options:**
+- `keyGenerator?: (...args) => string` - Custom key generation function
+- If not provided, uses `JSON.stringify(args)` as the key
+
+### 🗄️ InFlightWithCache
+
+Combines in-flight request deduplication with result caching.
+
+```typescript
+import { InFlightWithCache } from '@isa/common/decorators';
+
+@Injectable()
+class ProductService {
+ @InFlightWithCache({
+ cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
+ keyGenerator: (productId: string) => productId
+ })
+ async getProduct(productId: string): Promise {
+ // Results are cached for 5 minutes
+ // Multiple calls within cache time return cached result
+ return await this.http.get(`/api/products/${productId}`).toPromise();
+ }
+}
+```
+
+**Configuration Options:**
+- `cacheTime?: number` - Cache duration in milliseconds
+- `keyGenerator?: (...args) => string` - Custom key generation function
+
+## How It Works
+
+### Memory Management
+
+All decorators use `WeakMap` for memory efficiency:
+- Automatic garbage collection when instances are destroyed
+- No memory leaks
+- Per-instance state isolation
+
+### Error Handling
+
+- Failed requests are not cached
+- In-flight tracking is cleaned up on both success and error
+- All concurrent callers receive the same error
+
+### Thread Safety
+
+- Decorators are instance-aware
+- Each service instance has its own in-flight tracking
+- No shared state between instances
+
+## Real-World Examples
+
+### Solving Your Original Problem
+
+```typescript
+// Before: Multiple simultaneous calls
+@Injectable({ providedIn: 'root' })
+export class RemissionProductGroupService {
+ async fetchProductGroups(): Promise {
+ // Multiple calls = multiple API requests
+ return await this.apiCall();
+ }
+}
+
+// After: Using InFlight decorator
+@Injectable({ providedIn: 'root' })
+export class RemissionProductGroupService {
+ @InFlight()
+ async fetchProductGroups(): Promise {
+ // Multiple simultaneous calls = single API request
+ return await this.apiCall();
+ }
+}
+```
+
+### Advanced Scenarios
+
+```typescript
+@Injectable()
+class OrderService {
+ // Different cache times for different data types
+ @InFlightWithCache({ cacheTime: 30 * 1000 }) // 30 seconds
+ async getOrderStatus(orderId: string): Promise {
+ return await this.http.get(`/api/orders/${orderId}/status`).toPromise();
+ }
+
+ @InFlightWithCache({ cacheTime: 10 * 60 * 1000 }) // 10 minutes
+ async getOrderHistory(customerId: string): Promise {
+ return await this.http.get(`/api/customers/${customerId}/orders`).toPromise();
+ }
+
+ // Custom key generation for complex parameters
+ @InFlightWithKey({
+ keyGenerator: (filter: OrderFilter) =>
+ `${filter.status}-${filter.dateFrom}-${filter.dateTo}`
+ })
+ async searchOrders(filter: OrderFilter): Promise {
+ return await this.http.post('/api/orders/search', filter).toPromise();
+ }
+}
+```
+
+## Best Practices
+
+### âś… Do
+
+- Use `@InFlight()` for simple methods without parameters
+- Use `@InFlightWithKey()` for methods with parameters
+- Use `@InFlightWithCache()` for expensive operations with stable results
+- Provide custom `keyGenerator` for complex parameter objects
+- Set appropriate cache times based on data volatility
+
+### ❌ Don't
+
+- Use on methods that return different results for the same input
+- Use excessively long cache times for dynamic data
+- Use on methods that have side effects (POST, PUT, DELETE)
+- Rely on argument order for default key generation
+
+## Performance Considerations
+
+### Memory Usage
+
+- `InFlight`: Minimal memory overhead (one Promise per instance)
+- `InFlightWithKey`: Memory usage scales with unique parameter combinations
+- `InFlightWithCache`: Additional memory for cached results
+
+### Cleanup
+
+- In-flight requests are automatically cleaned up on completion
+- Cache entries are cleaned up on expiry
+- WeakMap ensures instances can be garbage collected
+
+## Testing
+
+The decorators are fully tested with comprehensive unit tests. Key test scenarios include:
+
+- Multiple simultaneous calls deduplication
+- Error handling and cleanup
+- Cache expiration
+- Instance isolation
+- Key generation
+
+Run tests with:
+```bash
+npx nx test common-decorators
+```
+
+## Migration Guide
+
+### From Manual Implementation
+
+```typescript
+// Before: Manual in-flight tracking
+class MyService {
+ private inFlight: Promise | null = null;
+
+ async fetchData(): Promise {
+ if (this.inFlight) {
+ return this.inFlight;
+ }
+
+ this.inFlight = this.doFetch();
+ try {
+ return await this.inFlight;
+ } finally {
+ this.inFlight = null;
+ }
+ }
+}
+
+// After: Using decorator
+class MyService {
+ @InFlight()
+ async fetchData(): Promise {
+ return await this.doFetch();
+ }
+}
+```
+
+### From RxJS shareReplay
+
+```typescript
+// Before: RxJS approach
+class MyService {
+ private data$ = this.http.get('/api/data').pipe(
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ getData(): Observable {
+ return this.data$;
+ }
+}
+
+// After: Promise-based with decorator
+class MyService {
+ @InFlightWithCache({ cacheTime: 5 * 60 * 1000 })
+ async getData(): Promise {
+ return await this.http.get('/api/data').toPromise();
+ }
+}
+```
+
+## Contributing
+
+When adding new decorators:
+1. Add implementation in `src/lib/`
+2. Include comprehensive unit tests
+3. Update this documentation
+4. Export from `src/index.ts`
\ No newline at end of file
diff --git a/libs/common/decorators/eslint.config.cjs b/libs/common/decorators/eslint.config.cjs
new file mode 100644
index 000000000..3484a6e9e
--- /dev/null
+++ b/libs/common/decorators/eslint.config.cjs
@@ -0,0 +1,34 @@
+const nx = require('@nx/eslint-plugin');
+const baseConfig = require('../../../eslint.config.js');
+
+module.exports = [
+ ...baseConfig,
+ ...nx.configs['flat/angular'],
+ ...nx.configs['flat/angular-template'],
+ {
+ files: ['**/*.ts'],
+ rules: {
+ '@angular-eslint/directive-selector': [
+ 'error',
+ {
+ type: 'attribute',
+ prefix: 'common',
+ style: 'camelCase',
+ },
+ ],
+ '@angular-eslint/component-selector': [
+ 'error',
+ {
+ type: 'element',
+ prefix: 'common',
+ style: 'kebab-case',
+ },
+ ],
+ },
+ },
+ {
+ files: ['**/*.html'],
+ // Override or add rules here
+ rules: {},
+ },
+];
diff --git a/libs/common/decorators/project.json b/libs/common/decorators/project.json
new file mode 100644
index 000000000..9b8aa0b54
--- /dev/null
+++ b/libs/common/decorators/project.json
@@ -0,0 +1,20 @@
+{
+ "name": "common-decorators",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/common/decorators/src",
+ "prefix": "common",
+ "projectType": "library",
+ "tags": [],
+ "targets": {
+ "test": {
+ "executor": "@nx/vite:test",
+ "outputs": ["{options.reportsDirectory}"],
+ "options": {
+ "reportsDirectory": "../../../coverage/libs/common/decorators"
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ }
+ }
+}
diff --git a/libs/common/decorators/src/index.ts b/libs/common/decorators/src/index.ts
new file mode 100644
index 000000000..b72c48b98
--- /dev/null
+++ b/libs/common/decorators/src/index.ts
@@ -0,0 +1 @@
+export * from './lib/in-flight.decorator';
\ No newline at end of file
diff --git a/libs/common/decorators/src/lib/in-flight.decorator.spec.ts b/libs/common/decorators/src/lib/in-flight.decorator.spec.ts
new file mode 100644
index 000000000..f71d0dea0
--- /dev/null
+++ b/libs/common/decorators/src/lib/in-flight.decorator.spec.ts
@@ -0,0 +1,321 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { InFlight, InFlightWithKey, InFlightWithCache } from './in-flight.decorator';
+
+describe('InFlight Decorators', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.clearAllTimers();
+ });
+
+ describe('InFlight', () => {
+ class TestService {
+ callCount = 0;
+
+ @InFlight()
+ async fetchData(delay = 100): Promise {
+ this.callCount++;
+ await new Promise(resolve => setTimeout(resolve, delay));
+ return `result-${this.callCount}`;
+ }
+
+ @InFlight()
+ async fetchWithError(delay = 100): Promise {
+ this.callCount++;
+ await new Promise(resolve => setTimeout(resolve, delay));
+ throw new Error('Test error');
+ }
+ }
+
+ it('should prevent multiple simultaneous calls', async () => {
+ const service = new TestService();
+
+ // Make three simultaneous calls
+ const promise1 = service.fetchData();
+ const promise2 = service.fetchData();
+ const promise3 = service.fetchData();
+
+ // Advance timers to complete the async operation
+ await vi.runAllTimersAsync();
+
+ // All promises should resolve to the same value
+ const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
+
+ expect(result1).toBe('result-1');
+ expect(result2).toBe('result-1');
+ expect(result3).toBe('result-1');
+ expect(service.callCount).toBe(1);
+ });
+
+ it('should allow subsequent calls after completion', async () => {
+ const service = new TestService();
+
+ // First call
+ const promise1 = service.fetchData();
+ await vi.runAllTimersAsync();
+ const result1 = await promise1;
+ expect(result1).toBe('result-1');
+
+ // Second call after first completes
+ const promise2 = service.fetchData();
+ await vi.runAllTimersAsync();
+ const result2 = await promise2;
+ expect(result2).toBe('result-2');
+
+ expect(service.callCount).toBe(2);
+ });
+
+ it('should handle errors properly', async () => {
+ const service = new TestService();
+
+ // Make multiple calls that will error
+ const promise1 = service.fetchWithError();
+ const promise2 = service.fetchWithError();
+
+ await vi.runAllTimersAsync();
+
+ // Both should reject with the same error
+ await expect(promise1).rejects.toThrow('Test error');
+ await expect(promise2).rejects.toThrow('Test error');
+ expect(service.callCount).toBe(1);
+
+ // Should allow new call after error
+ const promise3 = service.fetchWithError();
+ await vi.runAllTimersAsync();
+ await expect(promise3).rejects.toThrow('Test error');
+ expect(service.callCount).toBe(2);
+ });
+
+ it('should maintain separate state per instance', async () => {
+ const service1 = new TestService();
+ const service2 = new TestService();
+
+ // Make simultaneous calls on different instances
+ const promise1 = service1.fetchData();
+ const promise2 = service2.fetchData();
+
+ await vi.runAllTimersAsync();
+
+ const [result1, result2] = await Promise.all([promise1, promise2]);
+
+ // Each instance should have made its own call
+ expect(result1).toBe('result-1');
+ expect(result2).toBe('result-1');
+ expect(service1.callCount).toBe(1);
+ expect(service2.callCount).toBe(1);
+ });
+ });
+
+ describe('InFlightWithKey', () => {
+ class UserService {
+ callCounts = new Map();
+
+ @InFlightWithKey({
+ keyGenerator: (userId: string) => userId
+ })
+ async fetchUser(userId: string, delay = 100): Promise<{ id: string; name: string }> {
+ const count = (this.callCounts.get(userId) || 0) + 1;
+ this.callCounts.set(userId, count);
+
+ await new Promise(resolve => setTimeout(resolve, delay));
+ return { id: userId, name: `User ${userId} - Call ${count}` };
+ }
+
+ @InFlightWithKey()
+ async fetchWithDefaultKey(param1: string, param2: number): Promise {
+ const key = `${param1}-${param2}`;
+ const count = (this.callCounts.get(key) || 0) + 1;
+ this.callCounts.set(key, count);
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return `Result ${count}`;
+ }
+ }
+
+ it('should deduplicate calls with same key', async () => {
+ const service = new UserService();
+
+ // Multiple calls with same userId
+ const promise1 = service.fetchUser('user1');
+ const promise2 = service.fetchUser('user1');
+ const promise3 = service.fetchUser('user1');
+
+ await vi.runAllTimersAsync();
+
+ const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
+
+ expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
+ expect(result2).toEqual(result1);
+ expect(result3).toEqual(result1);
+ expect(service.callCounts.get('user1')).toBe(1);
+ });
+
+ it('should allow simultaneous calls with different keys', async () => {
+ const service = new UserService();
+
+ // Calls with different userIds
+ const promise1 = service.fetchUser('user1');
+ const promise2 = service.fetchUser('user2');
+ const promise3 = service.fetchUser('user1'); // Duplicate of first
+
+ await vi.runAllTimersAsync();
+
+ const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
+
+ expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
+ expect(result2).toEqual({ id: 'user2', name: 'User user2 - Call 1' });
+ expect(result3).toEqual(result1); // Same as first call
+
+ expect(service.callCounts.get('user1')).toBe(1);
+ expect(service.callCounts.get('user2')).toBe(1);
+ });
+
+ it('should use JSON.stringify as default key generator', async () => {
+ const service = new UserService();
+
+ // Multiple calls with same arguments
+ const promise1 = service.fetchWithDefaultKey('test', 123);
+ const promise2 = service.fetchWithDefaultKey('test', 123);
+
+ // Different arguments
+ const promise3 = service.fetchWithDefaultKey('test', 456);
+
+ await vi.runAllTimersAsync();
+
+ const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
+
+ expect(result1).toBe('Result 1');
+ expect(result2).toBe('Result 1'); // Same as first
+ expect(result3).toBe('Result 1'); // Different key, separate call
+
+ expect(service.callCounts.get('test-123')).toBe(1);
+ expect(service.callCounts.get('test-456')).toBe(1);
+ });
+ });
+
+ describe('InFlightWithCache', () => {
+ class DataService {
+ callCount = 0;
+
+ @InFlightWithCache({
+ cacheTime: 1000, // 1 second cache
+ keyGenerator: (query: string) => query
+ })
+ async search(query: string): Promise {
+ this.callCount++;
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return [`result-${query}-${this.callCount}`];
+ }
+
+ @InFlightWithCache({
+ cacheTime: 500
+ })
+ async fetchWithExpiry(id: number): Promise {
+ this.callCount++;
+ await new Promise(resolve => setTimeout(resolve, 50));
+ return `data-${id}-${this.callCount}`;
+ }
+ }
+
+ it('should cache results for specified time', async () => {
+ const service = new DataService();
+
+ // First call
+ const promise1 = service.search('test');
+ await vi.runAllTimersAsync();
+ const result1 = await promise1;
+ expect(result1).toEqual(['result-test-1']);
+ expect(service.callCount).toBe(1);
+
+ // Second call within cache time - should return cached result
+ const result2 = await service.search('test');
+ expect(result2).toEqual(['result-test-1']);
+ expect(service.callCount).toBe(1); // No new call
+
+ // Advance time past cache expiry
+ vi.advanceTimersByTime(1100);
+
+ // Third call after cache expiry - should make new call
+ const promise3 = service.search('test');
+ await vi.runAllTimersAsync();
+ const result3 = await promise3;
+ expect(result3).toEqual(['result-test-2']);
+ expect(service.callCount).toBe(2);
+ });
+
+ it('should handle in-flight deduplication with caching', async () => {
+ const service = new DataService();
+
+ // Multiple simultaneous calls
+ const promise1 = service.search('query1');
+ const promise2 = service.search('query1');
+ const promise3 = service.search('query1');
+
+ await vi.runAllTimersAsync();
+
+ const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
+
+ // All should get same result
+ expect(result1).toEqual(['result-query1-1']);
+ expect(result2).toEqual(result1);
+ expect(result3).toEqual(result1);
+ expect(service.callCount).toBe(1);
+
+ // Subsequent call should use cache
+ const result4 = await service.search('query1');
+ expect(result4).toEqual(['result-query1-1']);
+ expect(service.callCount).toBe(1);
+ });
+
+ it('should clean up expired cache entries', async () => {
+ const service = new DataService();
+
+ // Make a call
+ const promise1 = service.fetchWithExpiry(1);
+ await vi.runAllTimersAsync();
+ await promise1;
+
+ // Advance time past cache expiry
+ vi.advanceTimersByTime(600);
+
+ // Make another call - should not use expired cache
+ service.callCount = 0; // Reset for clarity
+ const promise2 = service.fetchWithExpiry(1);
+ await vi.runAllTimersAsync();
+ const result2 = await promise2;
+
+ expect(result2).toBe('data-1-1');
+ expect(service.callCount).toBe(1); // New call was made
+ });
+
+ it('should handle errors without caching them', async () => {
+ class ErrorService {
+ callCount = 0;
+
+ @InFlightWithCache({ cacheTime: 1000 })
+ async fetchWithError(): Promise {
+ this.callCount++;
+ await new Promise(resolve => setTimeout(resolve, 50));
+ throw new Error('API Error');
+ }
+ }
+
+ const service = new ErrorService();
+
+ // First call that errors
+ const promise1 = service.fetchWithError();
+ await vi.runAllTimersAsync();
+ await expect(promise1).rejects.toThrow('API Error');
+ expect(service.callCount).toBe(1);
+
+ // Second call should not use cache (errors aren't cached)
+ const promise2 = service.fetchWithError();
+ await vi.runAllTimersAsync();
+ await expect(promise2).rejects.toThrow('API Error');
+ expect(service.callCount).toBe(2);
+ });
+ });
+});
\ No newline at end of file
diff --git a/libs/common/decorators/src/lib/in-flight.decorator.ts b/libs/common/decorators/src/lib/in-flight.decorator.ts
new file mode 100644
index 000000000..e5ae18302
--- /dev/null
+++ b/libs/common/decorators/src/lib/in-flight.decorator.ts
@@ -0,0 +1,251 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/**
+ * Decorator that prevents multiple simultaneous calls to the same async method.
+ * All concurrent calls will receive the same Promise result.
+ *
+ * @example
+ * ```typescript
+ * class MyService {
+ * @InFlight()
+ * async fetchData(): Promise {
+ * // This method will only execute once even if called multiple times simultaneously
+ * return await api.getData();
+ * }
+ * }
+ * ```
+ */
+export function InFlight<
+ T extends (...args: any[]) => Promise,
+>(): MethodDecorator {
+ const inFlightMap = new WeakMap