> Meticulous records real user sessions from a web app and replays them against each commit to catch visual regressions.
>
> Full AI-readable docs index (complete project setup journey + every page as markdown): https://app.meticulous.ai/llms.txt

# Vue 3 with Vite - Complete Setup Guide

Complete guide for setting up Meticulous with Vue 3 applications built with Vite.

---

## Overview

Vue 3 with Vite provides a fast development experience. This guide covers:

- **Recorder installation** in `index.html`
- **CI/CD configuration** with static asset upload
- **Authentication handling**
- **Common patterns** and troubleshooting

**Prerequisites**:
- Vue 3 application using Vite
- Basic familiarity with [Meticulous concepts](/docs/onboarding-guide)

---

## Quick Start

### Step 1: Install Recorder in index.html

Add the Meticulous recorder script to your `index.html` **before any other scripts**.

**File**: `index.html`

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Your App Name</title>

    <!-- 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>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
```

**Important**: The recorder must load before your application code.

---

### Step 2: Configure GitHub Actions Workflow

Vue/Vite builds to static files, so we use the `upload-assets` action.

**File**: `.github/workflows/meticulous.yml`

```yaml
name: Meticulous

# Important: The workflow needs to run both on pushes to your main branch and on
# pull requests. It needs to run on your main branch because it'll use the results
# from the base commit of the PR on the main branch to compare against.
on:
  push:
    branches:
      - main
  pull_request: {}
  # Important: We need the workflow to be triggered on workflow_dispatch events,
  # so that Meticulous can run the workflow on the base commit to compare
  # against if an existing workflow hasn't run. The meticulous-commit-sha input
  # lets Meticulous ask for a specific commit (e.g. stacked PRs); without it,
  # a dispatched run can only build whatever the branch currently points at.
  workflow_dispatch:
    inputs:
      meticulous-commit-sha:
        description: Commit Meticulous has asked this run to build. Defaults to the branch head.
        required: false

# Important: The workflow needs all the permissions below.
# These permissions are mainly needed to post and update the status check and
# feedback comment on your PR. Meticulous won't work without them.
permissions:
  actions: write
  contents: read
  issues: write
  pull-requests: write
  statuses: read

env:
  # Prefer the dispatched commit when set; otherwise the PR head. On pull_request github.sha is the merge commit,
  # not the PR head SHA that Meticulous looks up.
  METICULOUS_COMMIT_SHA: ${{ github.event.inputs['meticulous-commit-sha'] || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}

jobs:
  test:
    name: Meticulous
    runs-on: ubuntu-latest

    steps:
      # Same workflow file as the upload step — ensure-base dispatches *this*
      # workflow on the base branch. Run it before checkout/build so the base
      # can start while this job continues. Needs no checkout. Pass the same
      # ref as checkout so we pre-warm the base the upload step will ask for.
      - name: Ensure base tests exist
        uses: alwaysmeticulous/report-diffs-action/ensure-base@v1
        with:
          api-token: ${{ secrets.METICULOUS_API_TOKEN }}
          ref: ${{ env.METICULOUS_COMMIT_SHA }}

      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          ref: ${{ env.METICULOUS_COMMIT_SHA }}

      - 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"
          rewrites: |
            [
              { "source": "/(.*)", "destination": "/index.html" }
            ]
```

---

### Step 3: Add API Token Secret

1. Get your API token from [Meticulous dashboard](https://app.meticulous.ai) → Project Settings
2. Go to GitHub repo → Settings → Secrets and variables → Actions
3. Create secret named `METICULOUS_API_TOKEN` with your token

---

## How It Works

### upload-assets Action

The `upload-assets` action uploads your built files and runs tests against them.

**Build output**: Vite builds to `dist/` directory by default.

### Rewrites Configuration

Handles client-side routing (Vue Router):

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

---

## Common Patterns

### Pattern 1: Detect Test Mode

Use `window.Meticulous.isRunningAsTest` in your components:

**Options API**:
```vue
<template>
  <div>
    <p v-if="isTest">Running as test</p>
    <p v-else>Running normally</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isTest: window.Meticulous?.isRunningAsTest || false
    }
  }
}
</script>
```

**Composition API**:
```vue
<template>
  <div>
    <p v-if="isTest">Running as test</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const isTest = ref(window.Meticulous?.isRunningAsTest || false)
</script>
```

### Pattern 2: Bypass Authentication

**File**: `src/main.ts`

```typescript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)

// Mock authentication for tests
if (window.Meticulous?.isRunningAsTest) {
  localStorage.setItem('auth-token', 'test-token')
  localStorage.setItem('user', JSON.stringify({
    id: 'test-user',
    name: 'Test User',
    email: 'test@example.com'
  }))
}

app.use(createPinia())
app.use(router)
app.mount('#app')
```

### Pattern 3: Router Navigation Guards

Handle authentication in router:

**File**: `src/router/index.ts`

```typescript
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocationNormalized } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('../views/HomeView.vue')
    },
    {
      path: '/dashboard',
      name: 'dashboard',
      component: () => import('../views/DashboardView.vue'),
      meta: { requiresAuth: true }
    }
  ]
})

router.beforeEach((to: RouteLocationNormalized) => {
  // Skip auth check during tests
  if (window.Meticulous?.isRunningAsTest) {
    return true
  }

  // Normal auth check
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  return true
})

export default router
```

### Pattern 4: Composable for Test Detection

Create a reusable composable:

**File**: `src/composables/useMeticulous.ts`

```typescript
import { ref, readonly } from 'vue'

export function useMeticulous() {
  const isRunningAsTest = ref(
    window.Meticulous?.isRunningAsTest || false
  )

  const recordCustomValues = (values: Record<string, any>) => {
    window.Meticulous?.recordCustomValues?.(values)
  }

  const getCustomValues = () => {
    return window.Meticulous?.getCustomValues?.()
  }

  return {
    isRunningAsTest: readonly(isRunningAsTest),
    recordCustomValues,
    getCustomValues
  }
}
```

**Usage**:
```vue
<script setup>
import { useMeticulous } from '@/composables/useMeticulous'

const { isRunningAsTest } = useMeticulous()
</script>
```

---

## Complete Example

### File Structure

```
your-app/
├── src/
│   ├── main.ts               # Entry point
│   ├── App.vue               # Root component
│   ├── router/
│   │   └── index.ts          # Router configuration
│   ├── stores/               # Pinia stores
│   ├── views/                # Page components
│   ├── components/           # Reusable components
│   └── composables/
│       └── useMeticulous.ts  # Test detection composable
├── index.html                # Recorder installation
├── vite.config.ts            # Vite configuration
├── .github/
│   └── workflows/
│       └── meticulous.yml    # CI/CD
└── package.json
```

### Example: Protected View

**File**: `src/views/DashboardView.vue`

```vue
<template>
  <div class="dashboard">
    <h1>Welcome, {{ user?.name }}!</h1>
    <p>Email: {{ user?.email }}</p>

    <div v-if="loading">
      <p>Loading dashboard...</p>
    </div>
    <div v-else-if="error">
      <p class="error">{{ error }}</p>
    </div>
    <div v-else>
      <!-- Dashboard content -->
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'

interface User {
  id: string
  name: string
  email: string
}

const router = useRouter()
const user = ref<User | null>(null)
const loading = ref(true)
const error = ref('')

onMounted(async () => {
  // Mock data for tests
  if (window.Meticulous?.isRunningAsTest) {
    user.value = {
      id: 'test-user-123',
      name: 'Test User',
      email: 'test@example.com'
    }
    loading.value = false
    return
  }

  // Normal data fetching
  try {
    const response = await fetch('/api/user')
    if (!response.ok) throw new Error('Failed to fetch user')
    user.value = await response.json()
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'Unknown error'
    router.push('/login')
  } finally {
    loading.value = false
  }
})
</script>
```

---

## CI/CD Configuration Details

### Environment Variables

Vite exposes environment variables prefixed with `VITE_`:

```yaml
- name: Build app
  run: npm run build
  env:
    VITE_API_URL: "https://api.example.com"
    VITE_APP_NAME: "My App"
    NODE_ENV: production
```

**In code**:
```typescript
const apiUrl = import.meta.env.VITE_API_URL
```

### TypeScript Configuration

Ensure TypeScript recognizes Vite env variables:

**File**: `src/env.d.ts`

```typescript
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string
  readonly VITE_APP_NAME: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}
```

---

## Troubleshooting

### Issue: Recorder Not Loading

**Symptom**: `window.Meticulous` is undefined

**Checks**:
1. Verify recorder script in `index.html` `<head>`
2. Check project ID is correct
3. Check for CSP blocking

**Fix**: Ensure correct placement:

```html
<head>
  <!-- Recorder FIRST -->
  <script data-project-id="..." src="https://snippet.meticulous.ai/v1/meticulous.js"></script>

  <!-- Then other elements -->
  <title>Your App</title>
</head>
```

---

### Issue: Routes Return 404

**Symptom**: Direct navigation to routes returns 404

**Cause**: Missing rewrite configuration

**Fix**:

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

---

### Issue: TypeScript Errors

**Symptom**: `Property 'Meticulous' does not exist on type 'Window'.`

**Fix**: Add type declarations

**File**: `src/types/meticulous.d.ts`

```typescript
interface Meticulous {
  isRunningAsTest?: boolean
  recordCustomValues?: (values: Record<string, any>) => void
  getCustomValues?: () => Record<string, any> | undefined
  pause?: () => void
  resume?: () => void
}

declare global {
  interface Window {
    Meticulous?: Meticulous
  }
}

export {}
```

---

### Issue: False Positive Diffs

**Common causes**:
1. Animations not completing
2. Random data
3. Timestamps

**Fixes**:

**Disable animations in tests**:
```vue
<template>
  <Transition :duration="transitionDuration">
    <div>Content</div>
  </Transition>
</template>

<script setup>
import { computed } from 'vue'

const transitionDuration = computed(() =>
  window.Meticulous?.isRunningAsTest ? 0 : 300
)
</script>
```

**Deterministic IDs**:
```typescript
const generateId = () => {
  if (window.Meticulous?.isRunningAsTest) {
    return 'test-id-12345'
  }
  return crypto.randomUUID()
}
```

**Ignore timestamps**:
```vue
<template>
  <span class="meticulous-ignore">
    {{ new Date().toLocaleString() }}
  </span>
</template>
```

Learn more: [Fix False Positive Diffs](/docs/how-to/fix-false-positive-diffs)

---

## Testing Best Practices

### 1. Handle Loading States

Always show loading states:

```vue
<template>
  <div v-if="loading">
    <SkeletonLoader />
  </div>
  <div v-else-if="error">
    <ErrorMessage :error="error" />
  </div>
  <div v-else>
    <!-- Content -->
  </div>
</template>
```

### 2. Use Suspense for Async Components

```vue
<template>
  <Suspense>
    <template #default>
      <AsyncComponent />
    </template>
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>
```

### 3. Test Locally First

```bash
# Build your app
npm run build

# Serve built files
npx serve dist

# Verify at http://localhost:3000
```

---

## Advanced Configuration

### Monorepo Setup

```yaml
- 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"
    rewrites: |
      [
        { "source": "/(.*)", "destination": "/index.html" }
      ]
```

### Custom Vite Config

**File**: `vite.config.ts`

```typescript
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import CssSourcemapPlugin from '@alwaysmeticulous/recorder-plugin/css-sourcemap'

export default defineConfig({
  plugins: [vue(), CssSourcemapPlugin()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  build: {
    outDir: 'dist',
    sourcemap: true
  }
})
```

**Important**: `build.sourcemap` covers JavaScript only — it does nothing for CSS. Vite emits no CSS source maps for
production builds at all, so without extra help Meticulous cannot attribute stylesheet coverage back to the files in your repo.
`@alwaysmeticulous/recorder-plugin/css-sourcemap` emits a `.css.map` for each CSS asset to close that gap.

The plugin disables Vite's CSS minification, which is what makes the maps accurate, so enable it on the build whose coverage
Meticulous collects rather than on every production build. See
[Viewing source coverage information](/docs/how-to/enable-source-coverage) for the accuracy details and the full set of options.

---

## See Also

- [Onboarding Guide](/docs/onboarding-guide) - General Meticulous setup
- [Troubleshoot Authentication](/docs/how-to/troubleshoot-auth) - Auth patterns and solutions
- [Fix False Positives](/docs/how-to/fix-false-positive-diffs) - Handle non-deterministic content
