Angular - Complete Setup Guide

Complete guide for setting up Meticulous with Angular applications.


Overview

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:


Quick Start

Step 1: Install Recorder in src/index.html

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.


Step 2: Configure GitHub Actions Workflow

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.


Step 3: Add API Token Secret

  1. Get your API token from Meticulous dashboard → Project Settings
  2. Go to GitHub repo → Settings → Secrets and variables → Actions
  3. Create secret named METICULOUS_API_TOKEN with your token

Finding Your App Name

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.


Common Patterns

Pattern 1: Detect Test Mode

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>

Pattern 2: Bypass Authentication

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'
      }))
    }
  }
}

Pattern 3: Route Guards

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
  }
}

Pattern 4: Service for Test Detection

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
    }
  }
}

Complete Example

File Structure

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

Example: Protected Component

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>

CI/CD Configuration Details

Environment Variables

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

Custom Output Directory

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"

Troubleshooting

Issue: Recorder Not Loading

Symptom: window.Meticulous is undefined

Checks:

  1. Verify recorder script in src/index.html <head>
  2. Check project ID is correct
  3. 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>

Issue: TypeScript Errors

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"
  ]
}

Issue: Wrong Output Directory

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

Issue: Routes Return 404

Symptom: Direct navigation to routes returns 404

Cause: Missing rewrite configuration

Fix:

rewrites: |
  [
    { "source": "/(.*)", "destination": "/index.html" }
  ]

Issue: False Positive Diffs

Common causes:

  1. Animations not completing
  2. Random data
  3. 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


Testing Best Practices

1. Handle Loading States

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>

2. Use Resolvers for Data

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()
  }
}

3. Test Locally First

# Build your app
npm run build

# Serve built files
npx http-server dist/your-app-name

# Verify at http://localhost:8080

Advanced Configuration

Monorepo Setup

- 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" }
      ]

Base Href Configuration

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" }
  ]

See Also