mirror of https://github.com/ghostfolio/ghostfolio
25 changed files with 785 additions and 235 deletions
@ -1,66 +1,39 @@ |
|||||
# End-to-End (E2E) Testing |
# End-to-End (E2E) Testing |
||||
|
|
||||
This project uses [Cypress](https://www.cypress.io/) for end-to-end (E2E) testing, which simulates real user interactions in a browser. The E2E tests are located primarily within the `devtools/` package. |
> [!IMPORTANT] |
||||
|
> Only use the setup guidelines in this file if there is no existing E2E testing framework configured in the workspace, or if the user has explicitly requested to change or set up E2E testing. |
||||
## Running E2E Tests |
|
||||
|
## Setting Up and Running E2E Tests |
||||
The primary way to run E2E tests is through the `pnpm` script defined in the root `package.json`. |
|
||||
|
Add supported E2E frameworks to the project using `ng add`: |
||||
1. **Build DevTools:** The E2E tests run against a built version of the devtools extension. You must build it first: |
|
||||
|
- **Playwright:** |
||||
```shell |
```shell |
||||
pnpm -F ng-devtools-mcp build:dev |
ng add playwright-ng-schematics |
||||
``` |
``` |
||||
|
- **Cypress:** |
||||
2. **Run Cypress:** Use the `cy:open` or `cy:run` script: |
```shell |
||||
- To open the interactive Cypress Test Runner: |
ng add @cypress/schematic |
||||
```shell |
``` |
||||
pnpm -F ng-devtools-mcp cy:open |
- **Nightwatch:** |
||||
``` |
```shell |
||||
- To run the tests headlessly in the terminal (ideal for CI): |
ng add @nightwatch/schematics |
||||
```shell |
``` |
||||
pnpm -F ng-devtools-mcp cy:run |
- **WebdriverIO:** |
||||
``` |
```shell |
||||
|
ng add @wdio/schematics |
||||
## Test Structure |
``` |
||||
|
- **Puppeteer:** |
||||
- **Configuration:** The main Cypress configuration is located at `devtools/cypress.json`. |
```shell |
||||
- **Specs:** Test files (specs) are located in `devtools/cypress/integration/`. |
ng add @puppeteer/ng-schematics |
||||
- **Custom Commands:** Reusable custom commands and actions are defined in `devtools/cypress/support/`. |
``` |
||||
|
|
||||
### Example E2E Test Snippet |
Run E2E tests: |
||||
|
|
||||
A typical test might look like this: |
```shell |
||||
|
ng e2e [project] [options] |
||||
```typescript |
|
||||
// in devtools/cypress/integration/profiler.spec.ts |
|
||||
|
|
||||
describe('Profiler', () => { |
|
||||
beforeEach(() => { |
|
||||
cy.visit('/?e2e-app'); |
|
||||
cy.wait(1000); |
|
||||
cy.get('ng-devtools-tabs').find('a').contains('Profiler').click(); |
|
||||
}); |
|
||||
|
|
||||
it('should record and display profiling data', () => { |
|
||||
// Find the record button and click it |
|
||||
cy.get('button[aria-label="start-recording-button"]').click(); |
|
||||
|
|
||||
// Interact with the test application to generate profiling data |
|
||||
cy.get('body').find('#cards button').first().click(); |
|
||||
cy.wait(500); |
|
||||
|
|
||||
// Stop recording |
|
||||
cy.get('button[aria-label="stop-recording-button"]').click(); |
|
||||
|
|
||||
// Assert that the flame graph is now visible |
|
||||
cy.get('ng-devtools-recording-timeline').find('canvas').should('be.visible'); |
|
||||
}); |
|
||||
}); |
|
||||
``` |
``` |
||||
|
|
||||
### Best Practices |
## Custom & Enterprise Testing Tools |
||||
|
|
||||
- **Use `data-` attributes:** Whenever possible, use `data-cy` or similar attributes for selecting elements to make tests more resilient to CSS or structural changes. |
For custom enterprise runners (e.g., Katalon Studio, TestCafe, Selenium), define execution commands in `package.json` scripts. |
||||
- **Custom Commands:** Encapsulate common sequences of actions into custom commands in the `support` directory to keep tests clean and readable. |
|
||||
- **Wait for Application State:** Use `cy.wait()` for arbitrary waits sparingly. Prefer to wait for specific UI elements to appear or for network requests to complete to avoid flaky tests. |
|
||||
|
|||||
@ -0,0 +1,132 @@ |
|||||
|
# Environment configuration |
||||
|
|
||||
|
## Configuration strategies |
||||
|
|
||||
|
Angular supports two main configuration strategies: |
||||
|
|
||||
|
- **Build-time configuration** using environment files |
||||
|
- **Runtime configuration** by loading values at application startup |
||||
|
|
||||
|
Choose the approach based on your deployment requirements. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Build-time configuration |
||||
|
|
||||
|
Environment files define configuration values that are replaced at build time. |
||||
|
|
||||
|
> **Security note:** Environment files are bundled into the client-side application. |
||||
|
> They are visible to anyone who can load the page. |
||||
|
> Never store sensitive information like API keys, secrets, or credentials in environment files. |
||||
|
> These values can be easily accessed by users. |
||||
|
|
||||
|
Generate environment files using the CLI: |
||||
|
|
||||
|
```bash |
||||
|
ng generate environments |
||||
|
``` |
||||
|
|
||||
|
This creates environment-specific files such as: |
||||
|
|
||||
|
```ts |
||||
|
// environment.ts |
||||
|
export const environment = { |
||||
|
apiUrl: 'https://api.example.com', |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// environment.development.ts |
||||
|
export const environment = { |
||||
|
apiUrl: 'http://localhost:3000', |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
Import the environment where needed: |
||||
|
|
||||
|
```ts |
||||
|
import {environment} from '../environments/environment'; |
||||
|
|
||||
|
const apiUrl = environment.apiUrl; |
||||
|
``` |
||||
|
|
||||
|
The Angular CLI replaces the appropriate file based on the build configuration. |
||||
|
|
||||
|
If you need a development-mode check, use `isDevMode()` from `@angular/core` instead of relying on a manually maintained `production` flag. |
||||
|
|
||||
|
> Changes to environment files require rebuilding the application. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Runtime configuration (advanced) |
||||
|
|
||||
|
In some scenarios, applications need to load configuration at runtime instead of build time. |
||||
|
|
||||
|
This allows the same build artifact to be deployed across multiple environments without rebuilding. |
||||
|
|
||||
|
A common approach is to load a JSON configuration file from the `assets` folder during application |
||||
|
initialization. |
||||
|
|
||||
|
### Example |
||||
|
|
||||
|
```json |
||||
|
// src/assets/config.json |
||||
|
{ |
||||
|
"apiUrl": "https://api.example.com" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Load the configuration before the application starts: |
||||
|
|
||||
|
```ts |
||||
|
import {Service, inject} from '@angular/core'; |
||||
|
import {HttpClient} from '@angular/common/http'; |
||||
|
|
||||
|
@Service() |
||||
|
export class AppConfigService { |
||||
|
private config!: {apiUrl: string}; |
||||
|
|
||||
|
private readonly http = inject(HttpClient); |
||||
|
|
||||
|
loadConfig() { |
||||
|
return this.http.get<AppConfig>('/assets/config.json').pipe( |
||||
|
tap((data) => { |
||||
|
this.config = data; |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
get apiUrl(): string { |
||||
|
return this.config.apiUrl; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Register the loader during application bootstrap: |
||||
|
|
||||
|
```ts |
||||
|
import {provideAppInitializer, inject} from '@angular/core'; |
||||
|
|
||||
|
provideAppInitializer(() => { |
||||
|
const config = inject(AppConfigService); |
||||
|
return config.loadConfig(); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
This ensures configuration is available before the application renders. |
||||
|
|
||||
|
> Runtime configuration is an advanced pattern and is not required for most applications. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Choosing a strategy |
||||
|
|
||||
|
| Criteria | Build-time | Runtime | |
||||
|
| ---------------------- | ---------- | ------------ | |
||||
|
| Change without rebuild | No | Yes | |
||||
|
| Startup performance | Faster | Slight delay | |
||||
|
| Complexity | Low | Moderate | |
||||
|
| Deployment flexibility | Limited | High | |
||||
|
|
||||
|
Use build-time configuration for most applications, and runtime configuration when you need to |
||||
|
deploy the same build across multiple environments. |
||||
@ -0,0 +1,108 @@ |
|||||
|
# HTTP communication with `HttpClient` and `httpResource` |
||||
|
|
||||
|
Use Angular's HTTP APIs for backend communication so requests participate in dependency injection, interceptors, transfer cache, and security features. |
||||
|
|
||||
|
## Setup |
||||
|
|
||||
|
In Angular v21 and later, `HttpClient` is available for injection by default. Add `provideHttpClient(...)` only when an app needs to configure HTTP features for a specific injector: |
||||
|
|
||||
|
```ts |
||||
|
import {provideHttpClient, withInterceptors} from '@angular/common/http'; |
||||
|
|
||||
|
export const appConfig = { |
||||
|
providers: [provideHttpClient(withInterceptors([authInterceptor]))], |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
- `HttpClient` uses the fetch backend by default. |
||||
|
- Use `withXhr()` only when upload progress events are required. Do not use `withXhr()` for server-side rendering. |
||||
|
- Use `provideHttpClient(...)` for feature configuration such as interceptors, XSRF options, XHR, or parent-request delegation. |
||||
|
- Calling `provideHttpClient()` with no features is not required for basic HTTP requests, but it configures the default HTTP feature set for that injector, including Angular's XSRF interceptor. |
||||
|
- Prefer `provideHttpClient(...)` over `HttpClientModule` for feature configuration, especially with multiple injectors. |
||||
|
- Use `withRequestsMadeViaParent()` when a child injector should add interceptors while still delegating to the parent HTTP chain. |
||||
|
|
||||
|
## `HttpClient` |
||||
|
|
||||
|
Encapsulate backend calls in injectable services, not components: |
||||
|
|
||||
|
```ts |
||||
|
import {HttpClient} from '@angular/common/http'; |
||||
|
import {Service, inject} from '@angular/core'; |
||||
|
|
||||
|
@Service() |
||||
|
export class UserService { |
||||
|
private readonly http = inject(HttpClient); |
||||
|
|
||||
|
getUser(id: string) { |
||||
|
return this.http.get<User>(`/api/users/${id}`); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Important rules: |
||||
|
|
||||
|
- `HttpClient` requests are cold `Observable`s. No request is sent until the `Observable` is subscribed to. Multiple subscriptions send multiple backend requests. |
||||
|
- Subscribe to mutation requests (`post`, `put`, `patch`, `delete`) so they execute. |
||||
|
- The generic type parameter is a type assertion only. Validate unknown backend data at runtime when the shape is not trusted. |
||||
|
- Use literal values for `responseType` and `observe`; if options are extracted, write values like `responseType: 'text' as const`. |
||||
|
- `HttpHeaders` and `HttpParams` are immutable; use the returned instance from `.set()` or `.append()`. |
||||
|
- Fetch options such as `timeout`, `cache`, `priority`, `mode`, `redirect`, `credentials`, `keepalive`, `referrer`, `referrerPolicy`, and `integrity` are supported where the backend supports them. `withCredentials: true` overrides `credentials`. |
||||
|
- Handle failures through `HttpErrorResponse`. Network and timeout failures use status `0`; backend failures use the server status code. |
||||
|
- Prefer the `async` pipe or `toSignal` for component reads so subscriptions are cleaned up. |
||||
|
|
||||
|
## Interceptors |
||||
|
|
||||
|
Prefer functional interceptors configured with `withInterceptors`. |
||||
|
|
||||
|
```ts |
||||
|
import { |
||||
|
HttpHandlerFn, |
||||
|
HttpRequest, |
||||
|
provideHttpClient, |
||||
|
withInterceptors, |
||||
|
} from '@angular/common/http'; |
||||
|
|
||||
|
export function authInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) { |
||||
|
return next(req.clone({setHeaders: {Authorization: 'Bearer token'}})); |
||||
|
} |
||||
|
|
||||
|
export const appConfig = { |
||||
|
providers: [provideHttpClient(withInterceptors([authInterceptor]))], |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
- Interceptors run in the order listed. |
||||
|
- Request and response objects are mostly immutable; clone before changing them. |
||||
|
- Request and response bodies are not deeply immutable. Avoid in-place body mutation because retries can run the same interceptor again. |
||||
|
- Use `inject()` inside functional interceptors for services. |
||||
|
- Use `HttpContextToken` for per-request metadata that interceptors need but the backend should not receive. |
||||
|
- Use DI-based interceptors only for existing code, and enable them with `withInterceptorsFromDi()`. |
||||
|
|
||||
|
## Security |
||||
|
|
||||
|
- `HttpClient` strips the XSSI prefix from JSON responses when present. |
||||
|
- `provideHttpClient()` configures XSRF protection by default for mutating relative and same-origin requests. It reads the `XSRF-TOKEN` cookie and sends the `X-XSRF-TOKEN` header. |
||||
|
- The backend must set the XSRF cookie and verify the header. Customize names with `withXsrfConfiguration(...)`; disable only deliberately with `withNoXsrfProtection()`. |
||||
|
|
||||
|
## `httpResource` |
||||
|
|
||||
|
Use `httpResource` to create an asynchronous derivation that fetches data over HTTP and exposes the result as reactive signals. |
||||
|
|
||||
|
```ts |
||||
|
import {httpResource} from '@angular/common/http'; |
||||
|
import {input} from '@angular/core'; |
||||
|
|
||||
|
export class UserProfile { |
||||
|
readonly userId = input.required<string>(); |
||||
|
readonly user = httpResource(() => `/api/users/${this.userId()}`); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- `httpResource` is eager. It sends a request when its reactive request computation runs, not when an `Observable` is subscribed. |
||||
|
- When a dependency changes, it cancels the pending request and sends the next one. |
||||
|
- Return `undefined` from the request function to skip a backend request. |
||||
|
- Prefer `httpResource` for reads. Use `HttpClient` directly for mutations such as `POST`, `PUT`, `PATCH`, and `DELETE`. |
||||
|
- Guard `value()` reads with `hasValue()`; reading `value()` while the resource is in an error state throws. |
||||
|
- Use `httpResource.text`, `httpResource.blob`, or `httpResource.arrayBuffer` for non-JSON responses. |
||||
|
- Use the `parse` option to validate or transform responses with a runtime schema. |
||||
|
- Read `headers()`, `statusCode()`, and `progress()` when response metadata or download progress is needed. Set `reportProgress: true` for progress events. |
||||
@ -0,0 +1,145 @@ |
|||||
|
# Pipes |
||||
|
|
||||
|
Pipes transform data declaratively inside Angular templates using the `|` operator. |
||||
|
|
||||
|
## Using pipes in templates |
||||
|
|
||||
|
Import the pipe class and add it to the component's `imports` array. |
||||
|
|
||||
|
```ts |
||||
|
import {Component} from '@angular/core'; |
||||
|
import {DatePipe, CurrencyPipe} from '@angular/common'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'app-invoice', |
||||
|
imports: [DatePipe, CurrencyPipe], |
||||
|
template: ` |
||||
|
<p>Date: {{ issuedOn | date: 'mediumDate' }}</p> |
||||
|
<p>Total: {{ amount | currency }}</p> |
||||
|
`, |
||||
|
}) |
||||
|
export class Invoice { |
||||
|
issuedOn = new Date(); |
||||
|
amount = 49.99; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Using pipe logic outside templates |
||||
|
|
||||
|
**Do NOT inject pipe classes into services or other classes.** Pipes are template operators, not injectable services. Injecting them causes DI errors in standalone contexts and creates unnecessary coupling. |
||||
|
|
||||
|
### Custom pipes — extract the transformation function |
||||
|
|
||||
|
Extract the logic into a plain function. The pipe delegates to it; services import the function directly. |
||||
|
|
||||
|
```ts |
||||
|
// kebab-case.ts |
||||
|
export function toKebabCase(value: string): string { |
||||
|
return value.toLowerCase().replace(/ /g, '-'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// kebab-case.pipe.ts |
||||
|
import {Pipe, PipeTransform} from '@angular/core'; |
||||
|
import {toKebabCase} from './kebab-case'; |
||||
|
|
||||
|
@Pipe({name: 'kebabCase'}) |
||||
|
export class KebabCasePipe implements PipeTransform { |
||||
|
transform(value: string): string { |
||||
|
return toKebabCase(value); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// formatter.service.ts — import the function, NOT the pipe |
||||
|
import {Injectable} from '@angular/core'; |
||||
|
import {toKebabCase} from './kebab-case'; |
||||
|
|
||||
|
@Injectable({providedIn: 'root'}) |
||||
|
export class FormatterService { |
||||
|
toSlug(title: string): string { |
||||
|
return toKebabCase(title); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Built-in locale-aware pipes — use standalone formatting functions |
||||
|
|
||||
|
`@angular/common` exports a standalone function for each locale-aware built-in pipe: |
||||
|
|
||||
|
| Pipe | Standalone function | |
||||
|
| -------------- | ------------------- | |
||||
|
| `DatePipe` | `formatDate` | |
||||
|
| `CurrencyPipe` | `formatCurrency` | |
||||
|
| `DecimalPipe` | `formatNumber` | |
||||
|
| `PercentPipe` | `formatPercent` | |
||||
|
|
||||
|
Inject `LOCALE_ID` to get the current locale and pass it to the function. |
||||
|
|
||||
|
```ts |
||||
|
// CORRECT — use formatNumber instead of injecting DecimalPipe |
||||
|
import {Injectable, LOCALE_ID, inject} from '@angular/core'; |
||||
|
import {formatNumber} from '@angular/common'; |
||||
|
|
||||
|
@Injectable({providedIn: 'root'}) |
||||
|
export class PriceService { |
||||
|
private locale = inject(LOCALE_ID); |
||||
|
|
||||
|
formatQuantity(value: number): string { |
||||
|
return formatNumber(value, this.locale, '1.0-0'); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
```ts |
||||
|
// WRONG — do not inject pipe classes |
||||
|
import {Injectable} from '@angular/core'; |
||||
|
import {DecimalPipe} from '@angular/common'; |
||||
|
|
||||
|
@Injectable({providedIn: 'root'}) |
||||
|
export class PriceService { |
||||
|
// ❌ DecimalPipe is not designed to be injected |
||||
|
private pipe = inject(DecimalPipe); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Creating custom pipes |
||||
|
|
||||
|
Use the Angular CLI to generate a pipe: |
||||
|
|
||||
|
```bash |
||||
|
ng generate pipe path/to/my-pipe |
||||
|
``` |
||||
|
|
||||
|
A pipe needs a `@Pipe` decorator with a `name` and a `transform` method implementing `PipeTransform`. |
||||
|
|
||||
|
```ts |
||||
|
import {Pipe, PipeTransform} from '@angular/core'; |
||||
|
|
||||
|
@Pipe({name: 'truncate'}) |
||||
|
export class TruncatePipe implements PipeTransform { |
||||
|
transform(value: string, limit = 50): string { |
||||
|
return value.length > limit ? value.slice(0, limit) + '…' : value; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- **`name`**: camelCase. Do not use hyphens. |
||||
|
- **Class name**: PascalCase version of `name` with `Pipe` appended (e.g., `TruncatePipe`). |
||||
|
|
||||
|
## Impure pipes |
||||
|
|
||||
|
Mark a pipe `pure: false` only when you need to detect mutations inside arrays or objects. Impure pipes run on every change-detection cycle and can hurt performance. |
||||
|
|
||||
|
```ts |
||||
|
@Pipe({name: 'filterItems', pure: false}) |
||||
|
export class FilterItemsPipe implements PipeTransform { |
||||
|
transform(items: string[], query: string): string[] { |
||||
|
return items.filter((i) => i.includes(query)); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
IMPORTANT: Avoid impure pipes unless absolutely necessary. |
||||
Loading…
Reference in new issue