Complete guide for setting up Meticulous with Angular applications.
Angular is a comprehensive framework for building web applications. This guide covers:
- Recorder installation in
src/index.html - CI/CD configuration with static asset upload
- Authentication handling
- Common patterns and troubleshooting
Prerequisites:
- Angular application (version 12+)
- Basic familiarity with Meticulous concepts
Add the Meticulous recorder script to your src/index.html before any other scripts.
File: src/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Your App Name</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Meticulous recorder - MUST be first script -->
<!-- Replace YOUR_PROJECT_ID with your project ID from the dashboard -->
<script
data-project-id="YOUR_PROJECT_ID"
src="https://snippet.meticulous.ai/v1/meticulous.js"
></script>
</head>
<body>
<app-root></app-root>
</body>
</html>
Important: The recorder must load before Angular bootstraps.
Angular builds to static files, so we use the upload-assets action.
File: .github/workflows/meticulous.yml
name: Meticulous Tests
on:
push:
branches: [main]
pull_request: {}
workflow_dispatch: {}
permissions:
actions: write
contents: read
issues: write
pull-requests: write
statuses: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build app
run: npm run build
env:
NODE_ENV: production
- name: Upload and test
uses: alwaysmeticulous/report-diffs-action/upload-assets@v1
with:
api-token: ${{ secrets.METICULOUS_API_TOKEN }}
app-directory: "dist/your-app-name"
rewrites: |
[
{ "source": "/(.*)", "destination": "/index.html" }
]
Important: Replace your-app-name with your actual app name from angular.json.
- Get your API token from Meticulous dashboard → Project Settings
- Go to GitHub repo → Settings → Secrets and variables → Actions
- Create secret named
METICULOUS_API_TOKENwith your token
The build output directory depends on your app name in angular.json:
File: angular.json
{
"projects": {
"my-angular-app": {
"architect": {
"build": {
"options": {
"outputPath": "dist/my-angular-app"
}
}
}
}
}
}
Use dist/my-angular-app as the app-directory in your workflow.
For a quick check, you can access window.Meticulous directly. For a cleaner, reusable approach, see Pattern 4: Service for Test Detection below.
Component:
import { Component, OnInit } from '@angular/core'
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit {
isTest = false
ngOnInit() {
this.isTest = (window as any).Meticulous?.isRunningAsTest || false
if (this.isTest) {
// Skip animations, use test data, etc.
}
}
}
Template:
<div *ngIf="isTest" class="test-indicator">
Running as test
</div>
File: src/app/app.component.ts
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent implements OnInit {
constructor(private router: Router) {}
ngOnInit() {
// Mock authentication for tests
if ((window as any).Meticulous?.isRunningAsTest) {
localStorage.setItem('auth-token', 'test-token')
localStorage.setItem('user', JSON.stringify({
id: 'test-user',
name: 'Test User',
email: 'test@example.com'
}))
}
}
}
Handle authentication in route guards:
File: src/app/guards/auth.guard.ts
import { Injectable } from '@angular/core'
import { Router, CanActivate } from '@angular/router'
import { AuthService } from '../services/auth.service'
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) {}
canActivate(): boolean {
// Skip auth check during tests
if ((window as any).Meticulous?.isRunningAsTest) {
return true
}
// Normal auth check
if (this.authService.isAuthenticated()) {
return true
}
this.router.navigate(['/login'])
return false
}
}
Create a reusable service:
File: src/app/services/meticulous.service.ts
import { Injectable } from '@angular/core'
interface MeticulousWindow extends Window {
Meticulous?: {
isRunningAsTest?: boolean
recordCustomValues?: (values: Record<string, any>) => void
getCustomValues?: () => Record<string, any> | undefined
}
}
@Injectable({
providedIn: 'root'
})
export class MeticulousService {
get isRunningAsTest(): boolean {
return (window as MeticulousWindow).Meticulous?.isRunningAsTest || false
}
recordCustomValues(values: Record<string, any>): void {
(window as MeticulousWindow).Meticulous?.recordCustomValues?.(values)
}
getCustomValues(): Record<string, any> | undefined {
return (window as MeticulousWindow).Meticulous?.getCustomValues?.()
}
}
Usage:
import { Component, OnInit } from '@angular/core'
import { MeticulousService } from './services/meticulous.service'
@Component({
selector: 'app-my-component',
templateUrl: './my-component.component.html'
})
export class MyComponent implements OnInit {
constructor(private meticulous: MeticulousService) {}
ngOnInit() {
if (this.meticulous.isRunningAsTest) {
// Test-specific logic
}
}
}
your-app/
├── src/
│ ├── app/
│ │ ├── app.component.ts # Root component
│ │ ├── app-routing.module.ts # Router configuration
│ │ ├── guards/
│ │ │ └── auth.guard.ts # Auth guard
│ │ ├── services/
│ │ │ ├── auth.service.ts # Auth service
│ │ │ └── meticulous.service.ts
│ │ └── components/
│ ├── index.html # Recorder installation
│ └── main.ts # Bootstrap
├── angular.json # Angular configuration
├── .github/
│ └── workflows/
│ └── meticulous.yml # CI/CD
└── package.json
File: src/app/components/dashboard/dashboard.component.ts
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { MeticulousService } from '../../services/meticulous.service'
interface User {
id: string
name: string
email: string
}
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.css']
})
export class DashboardComponent implements OnInit {
user: User | null = null
loading = true
error = ''
constructor(
private router: Router,
private meticulous: MeticulousService
) {}
async ngOnInit() {
// Mock data for tests
if (this.meticulous.isRunningAsTest) {
this.user = {
id: 'test-user-123',
name: 'Test User',
email: 'test@example.com'
}
this.loading = false
return
}
// Normal data fetching
try {
const response = await fetch('/api/user')
if (!response.ok) throw new Error('Failed to fetch user')
this.user = await response.json()
} catch (err) {
this.error = err instanceof Error ? err.message : 'Unknown error'
this.router.navigate(['/login'])
} finally {
this.loading = false
}
}
}
File: src/app/components/dashboard/dashboard.component.html
<div class="dashboard">
<h1 *ngIf="user">Welcome, {{ user.name }}!</h1>
<div *ngIf="loading">
<p>Loading dashboard...</p>
</div>
<div *ngIf="error" class="error">
<p>{{ error }}</p>
</div>
<div *ngIf="!loading && !error && user">
<p>Email: {{ user.email }}</p>
<!-- Dashboard content -->
</div>
</div>
Angular doesn't have built-in support for runtime environment variables in the browser. Use build-time configuration:
File: src/environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: 'https://api.example.com'
}
In workflow:
- name: Build app
run: npm run build -- --configuration production
If you have a custom output directory in angular.json:
{
"architect": {
"build": {
"options": {
"outputPath": "build"
}
}
}
}
Update workflow:
- name: Upload and test
uses: alwaysmeticulous/report-diffs-action/upload-assets@v1
with:
api-token: ${{ secrets.METICULOUS_API_TOKEN }}
app-directory: "build"
Symptom: window.Meticulous is undefined
Checks:
- Verify recorder script in
src/index.html<head> - Check project ID is correct
- Check for CSP blocking
Fix: Ensure correct placement:
<head>
<!-- Recorder FIRST -->
<script data-project-id="..." src="https://snippet.meticulous.ai/v1/meticulous.js"></script>
<!-- Then other elements -->
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
Symptom: Property 'Meticulous' does not exist on type 'Window'.
Fix: Add type declarations
File: src/typings.d.ts
interface Meticulous {
isRunningAsTest?: boolean
recordCustomValues?: (values: Record<string, any>) => void
getCustomValues?: () => Record<string, any> | undefined
pause?: () => void
resume?: () => void
}
declare interface Window {
Meticulous?: Meticulous
}
Update: tsconfig.app.json
{
"files": [
"src/main.ts",
"src/typings.d.ts"
]
}
Symptom: app-directory "dist/your-app-name" not found
Cause: App name doesn't match angular.json
Fix: Check angular.json for correct output path:
# Find your app name
cat angular.json | grep "outputPath"
# Output example: "outputPath": "dist/my-app"
# Use "dist/my-app" in workflow
Symptom: Direct navigation to routes returns 404
Cause: Missing rewrite configuration
Fix:
rewrites: |
[
{ "source": "/(.*)", "destination": "/index.html" }
]
Common causes:
- Animations not completing
- Random data
- Timestamps
Fixes:
Disable animations in tests:
import { Component, OnInit } from '@angular/core'
import { MeticulousService } from './services/meticulous.service'
@Component({
selector: 'app-my-component',
animations: [/* your animations */]
})
export class MyComponent implements OnInit {
animationState = 'initial'
constructor(private meticulous: MeticulousService) {}
ngOnInit() {
// Skip animations in tests
if (this.meticulous.isRunningAsTest) {
this.animationState = 'final'
}
}
}
Deterministic values:
generateId(): string {
if ((window as any).Meticulous?.isRunningAsTest) {
return 'test-id-12345'
}
return crypto.randomUUID()
}
Ignore timestamps:
<span class="meticulous-ignore">
{{ currentDate | date:'medium' }}
</span>
Learn more: Fix False Positive Diffs
Always show loading states:
<div *ngIf="loading">
<app-skeleton-loader></app-skeleton-loader>
</div>
<div *ngIf="!loading && !error">
<!-- Content -->
</div>
<div *ngIf="error">
<app-error-message [error]="error"></app-error-message>
</div>
Angular resolvers ensure data is loaded before navigation:
import { Injectable } from '@angular/core'
import { Resolve } from '@angular/router'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root'
})
export class UserResolver implements Resolve<User> {
constructor(
private userService: UserService,
private meticulous: MeticulousService
) {}
resolve(): Observable<User> | Promise<User> | User {
if (this.meticulous.isRunningAsTest) {
return {
id: 'test-user',
name: 'Test User',
email: 'test@example.com'
}
}
return this.userService.getUser()
}
}
# Build your app
npm run build
# Serve built files
npx http-server dist/your-app-name
# Verify at http://localhost:8080
- name: Install dependencies
working-directory: ./apps/frontend
run: npm ci
- name: Build app
working-directory: ./apps/frontend
run: npm run build
- name: Upload and test
uses: alwaysmeticulous/report-diffs-action/upload-assets@v1
with:
api-token: ${{ secrets.METICULOUS_API_TOKEN }}
app-directory: "./apps/frontend/dist/frontend"
rewrites: |
[
{ "source": "/(.*)", "destination": "/index.html" }
]
If your app is served from a subdirectory:
In angular.json:
{
"build": {
"options": {
"baseHref": "/my-app/"
}
}
}
In workflow:
rewrites: |
[
{ "source": "/my-app/(.*)", "destination": "/index.html" }
]
- Onboarding Guide - General Meticulous setup
- Troubleshoot Authentication - Auth patterns and solutions
- Fix False Positives - Handle non-deterministic content