Reference

Reference Untitled

Untitled

OtherEvergreenPublic

Nuxt 3 Testing Configuration

Package.json Dependencies

{
  "name": "design-system-nuxt3",
  "private": true,
  "scripts": {
    "build": "nuxt build",
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "postinstall": "nuxt prepare",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage",
    "test:ci": "vitest run --coverage",
    "test:e2e": "playwright test",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:debug": "playwright test --debug",
    "test:visual": "test-storybook",
    "test:a11y": "vitest --run --testPathPattern=a11y",
    "test:all": "npm run test:ci && npm run test:e2e && npm run test:visual",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "chromatic": "chromatic --exit-zero-on-changes",
    "bundlesize": "nuxi analyze",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "typecheck": "nuxt typecheck"
  },
  "devDependencies": {
    "@nuxt/devtools": "latest",
    "@nuxt/eslint-config": "^0.2.0",
    "@nuxt/test-utils": "^3.9.0",
    "@nuxt/ui": "^2.11.1",
    "@playwright/test": "^1.40.0",
    "@storybook/addon-a11y": "^7.6.6",
    "@storybook/addon-essentials": "^7.6.6",
    "@storybook/addon-interactions": "^7.6.6",
    "@storybook/addon-links": "^7.6.6",
    "@storybook/blocks": "^7.6.6",
    "@storybook/test-runner": "^0.16.0",
    "@storybook/testing-library": "^0.2.2",
    "@storybook/vue3": "^7.6.6",
    "@storybook/vue3-vite": "^7.6.6",
    "@vue/test-utils": "^2.4.3",
    "axe-playwright": "^1.2.3",
    "chromatic": "^10.1.0",
    "eslint": "^8.55.0",
    "happy-dom": "^12.10.3",
    "nuxt": "^3.9.0",
    "storybook": "^7.6.6",
    "typescript": "^5.3.3",
    "vitest": "^1.1.0",
    "vitest-axe": "^0.1.0",
    "@vitest/coverage-v8": "^1.1.0",
    "@vitest/ui": "^1.1.0"
  },
  "dependencies": {
    "@headlessui/vue": "^1.7.16",
    "@nuxtjs/tailwindcss": "^6.8.4",
    "@phosphor-icons/vue": "^2.1.6",
    "vue": "^3.3.13"
  }
}

Nuxt Configuration

File: nuxt.config.ts

export default defineNuxtConfig({
  devtools: { enabled: true },

  modules: [
    '@nuxtjs/tailwindcss',
    '@nuxt/ui',
    '@nuxt/test-utils/module'
  ],

  css: ['~/assets/css/main.css'],

  components: [
    {
      path: '~/components',
      pathPrefix: false,
    }
  ],

  typescript: {
    strict: true,
    typeCheck: true
  },

  // Testing configuration
  testUtils: {
    startOnBoot: false
  },

  // Build optimization for testing
  build: {
    analyze: {
      analyzerMode: 'static',
      openAnalyzer: false,
      generateStatsFile: true,
      statsFilename: 'stats.json'
    }
  },

  // Runtime config for testing
  runtimeConfig: {
    public: {
      testEnvironment: process.env.NODE_ENV === 'test'
    }
  },

  // Tailwind CSS configuration
  tailwindcss: {
    configPath: 'tailwind.config.ts',
    exposeConfig: true,
    viewer: true
  }
})

Vitest Configuration

File: vitest.config.ts

import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom',
    setupFiles: ['./tests/setup.ts'],
    include: [
      'components/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
      'composables/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
      'utils/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
      'tests/unit/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'
    ],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: [
        'components/**/*.{vue,js,ts}',
        'composables/**/*.{js,ts}',
        'utils/**/*.{js,ts}'
      ],
      exclude: [
        'node_modules/',
        '.nuxt/',
        'tests/',
        '**/*.d.ts',
        '**/*.stories.{js,ts,vue}',
        '**/index.{js,ts}'
      ],
      thresholds: {
        global: {
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80
        }
      }
    },
    globals: true,
    transformMode: {
      web: [/\.[jt]sx?$/],
      ssr: [/\.vue$/]
    }
  },
  resolve: {
    alias: {
      '~': resolve(__dirname, '.'),
      '@': resolve(__dirname, '.'),
      '~~': resolve(__dirname, '.'),
      '@@': resolve(__dirname, '.')
    }
  },
  define: {
    __VUE_OPTIONS_API__: true,
    __VUE_PROD_DEVTOOLS__: false
  }
})

Test Setup

File: tests/setup.ts

import { config } from '@vue/test-utils'
import { createNuxtApp } from '#app'
import 'vitest-axe/extend-expect'

// Mock Nuxt composables
vi.mock('#app', () => ({
  useNuxtApp: () => ({
    $router: {
      push: vi.fn(),
      replace: vi.fn(),
      go: vi.fn(),
      back: vi.fn(),
      forward: vi.fn()
    }
  }),
  navigateTo: vi.fn(),
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    go: vi.fn(),
    back: vi.fn(),
    forward: vi.fn()
  }),
  useRoute: () => ({
    path: '/',
    params: {},
    query: {},
    hash: '',
    name: 'index'
  }),
  useHead: vi.fn(),
  useSeoMeta: vi.fn(),
  useState: vi.fn(),
  useRuntimeConfig: () => ({
    public: {
      testEnvironment: true
    }
  })
}))

// Mock Phosphor Icons
vi.mock('@phosphor-icons/vue', () => ({
  PhCheckCircle: { template: '<div data-testid="check-circle-icon" />' },
  PhWarning: { template: '<div data-testid="warning-icon" />' },
  PhXCircle: { template: '<div data-testid="x-circle-icon" />' },
  PhInfo: { template: '<div data-testid="info-icon" />' },
  PhSpinner: { template: '<div data-testid="spinner-icon" />' },
  PhCursorClick: { template: '<div data-testid="cursor-click-icon" />' }
}))

// Global test configuration
config.global.mocks = {
  $router: {
    push: vi.fn(),
    replace: vi.fn()
  },
  $route: {
    path: '/',
    params: {},
    query: {}
  }
}

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  observe() { return null }
  disconnect() { return null }
  unobserve() { return null }
}

// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
  constructor() {}
  observe() { return null }
  disconnect() { return null }
  unobserve() { return null }
}

// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})

Button Component (Nuxt 3 Version)

File: components/Button.vue

<template>
  <button
    :type="type"
    :class="buttonClasses"
    :disabled="disabled || loading"
    :aria-label="ariaLabel"
    :data-testid="testId"
    @click="handleClick"
  >
    <PhSpinner
      v-if="loading"
      class="btn-icon animate-spin"
      :size="iconSize"
    />
    <component
      v-else-if="icon"
      :is="icon"
      class="btn-icon"
      :size="iconSize"
    />

    <span class="btn-text">
      <slot />
    </span>
  </button>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { PhSpinner } from '@phosphor-icons/vue'

export interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'accent' | 'ghost' | 'outline'
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  disabled?: boolean
  loading?: boolean
  icon?: any
  type?: 'button' | 'submit' | 'reset'
  ariaLabel?: string
  testId?: string
}

interface ButtonEmits {
  click: [event: MouseEvent]
}

const props = withDefaults(defineProps<ButtonProps>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
  loading: false,
  type: 'button'
})

const emit = defineEmits<ButtonEmits>()

const buttonClasses = computed(() => {
  const baseClasses = 'btn'
  const variantClasses = `btn-${props.variant}`
  const sizeClasses = `btn-${props.size}`
  const stateClasses = [
    props.loading && 'btn-loading',
    props.disabled && 'btn-disabled'
  ].filter(Boolean).join(' ')

  return [baseClasses, variantClasses, sizeClasses, stateClasses]
    .filter(Boolean)
    .join(' ')
})

const iconSize = computed(() => {
  const sizeMap = {
    xs: 12,
    sm: 14,
    md: 16,
    lg: 18,
    xl: 20
  }
  return sizeMap[props.size]
})

const handleClick = (event: MouseEvent) => {
  if (!props.disabled && !props.loading) {
    emit('click', event)
  }
}
</script>

<style scoped>
/* Button base styles */
.btn {
  @apply inline-flex items-center justify-center gap-2 px-4 py-2
         text-sm font-medium rounded-md transition-all duration-200
         focus:outline-none focus:ring-2 focus:ring-offset-2
         disabled:opacity-50 disabled:cursor-not-allowed;
}

/* Variant styles */
.btn-primary {
  @apply bg-primary text-white hover:bg-primary/90
         focus:ring-primary/50 active:bg-primary/95;
}

.btn-secondary {
  @apply bg-secondary text-white hover:bg-secondary/90
         focus:ring-secondary/50 active:bg-secondary/95;
}

.btn-accent {
  @apply bg-accent text-white hover:bg-accent/90
         focus:ring-accent/50 active:bg-accent/95;
}

.btn-ghost {
  @apply bg-transparent text-gray-700 hover:bg-gray-100
         focus:ring-gray-500/50 active:bg-gray-200;
}

.btn-outline {
  @apply bg-transparent border border-primary text-primary
         hover:bg-primary hover:text-white focus:ring-primary/50;
}

/* Size variants */
.btn-xs { @apply text-xs px-2 py-1; }
.btn-sm { @apply text-sm px-3 py-1.5; }
.btn-md { @apply text-sm px-4 py-2; }
.btn-lg { @apply text-base px-6 py-3; }
.btn-xl { @apply text-lg px-8 py-4; }

/* State styles */
.btn-loading {
  @apply cursor-wait;
}

.btn-icon {
  @apply flex-shrink-0;
}

.btn-text {
  @apply whitespace-nowrap;
}
</style>

Button Tests (Nuxt 3 Version)

File: components/Button.test.ts

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { axe } from 'vitest-axe'
import { PhCheckCircle } from '@phosphor-icons/vue'
import Button from './Button.vue'

describe('Button Component', () => {
  describe('Rendering', () => {
    it('renders with default props', () => {
      const wrapper = mount(Button, {
        slots: {
          default: 'Click me'
        }
      })

      expect(wrapper.find('button').exists()).toBe(true)
      expect(wrapper.text()).toBe('Click me')
      expect(wrapper.classes()).toContain('btn')
      expect(wrapper.classes()).toContain('btn-primary')
      expect(wrapper.classes()).toContain('btn-md')
    })

    it('renders with custom variant', () => {
      const wrapper = mount(Button, {
        props: { variant: 'secondary' },
        slots: { default: 'Secondary' }
      })

      expect(wrapper.classes()).toContain('btn-secondary')
    })

    it('renders with custom size', () => {
      const wrapper = mount(Button, {
        props: { size: 'lg' },
        slots: { default: 'Large' }
      })

      expect(wrapper.classes()).toContain('btn-lg')
    })

    it('renders with icon', () => {
      const wrapper = mount(Button, {
        props: { icon: PhCheckCircle },
        slots: { default: 'With Icon' }
      })

      expect(wrapper.findComponent(PhCheckCircle).exists()).toBe(true)
    })
  })

  describe('States', () => {
    it('handles disabled state', () => {
      const wrapper = mount(Button, {
        props: { disabled: true },
        slots: { default: 'Disabled' }
      })

      const button = wrapper.find('button')
      expect(button.attributes('disabled')).toBeDefined()
      expect(wrapper.classes()).toContain('btn-disabled')
    })

    it('handles loading state', () => {
      const wrapper = mount(Button, {
        props: { loading: true },
        slots: { default: 'Loading' }
      })

      const button = wrapper.find('button')
      expect(button.attributes('disabled')).toBeDefined()
      expect(wrapper.classes()).toContain('btn-loading')
    })

    it('shows spinner in loading state', () => {
      const wrapper = mount(Button, {
        props: { loading: true },
        slots: { default: 'Loading' }
      })

      expect(wrapper.find('[data-testid="spinner-icon"]').exists()).toBe(true)
    })
  })

  describe('Interactions', () => {
    it('emits click events', async () => {
      const wrapper = mount(Button, {
        slots: { default: 'Click me' }
      })

      await wrapper.find('button').trigger('click')
      expect(wrapper.emitted().click).toBeTruthy()
      expect(wrapper.emitted().click).toHaveLength(1)
    })

    it('does not emit click when disabled', async () => {
      const wrapper = mount(Button, {
        props: { disabled: true },
        slots: { default: 'Disabled' }
      })

      await wrapper.find('button').trigger('click')
      expect(wrapper.emitted().click).toBeFalsy()
    })

    it('does not emit click when loading', async () => {
      const wrapper = mount(Button, {
        props: { loading: true },
        slots: { default: 'Loading' }
      })

      await wrapper.find('button').trigger('click')
      expect(wrapper.emitted().click).toBeFalsy()
    })

    it('handles keyboard events', async () => {
      const wrapper = mount(Button, {
        slots: { default: 'Press Enter' }
      })

      await wrapper.find('button').trigger('keydown.enter')
      // Note: Button doesn't emit on keydown, but we test the element receives the event
      expect(wrapper.find('button').exists()).toBe(true)
    })
  })

  describe('Accessibility', () => {
    it('should not have accessibility violations', async () => {
      const wrapper = mount(Button, {
        slots: { default: 'Accessible Button' }
      })

      const results = await axe(wrapper.element)
      expect(results).toHaveNoViolations()
    })

    it('supports custom aria-label', () => {
      const wrapper = mount(Button, {
        props: { ariaLabel: 'Custom label' },
        slots: { default: 'Icon only' }
      })

      expect(wrapper.find('button').attributes('aria-label')).toBe('Custom label')
    })

    it('supports test id', () => {
      const wrapper = mount(Button, {
        props: { testId: 'test-button' },
        slots: { default: 'Test Button' }
      })

      expect(wrapper.find('button').attributes('data-testid')).toBe('test-button')
    })
  })

  describe('Props validation', () => {
    it('accepts all valid variants', () => {
      const variants = ['primary', 'secondary', 'accent', 'ghost', 'outline']

      variants.forEach(variant => {
        const wrapper = mount(Button, {
          props: { variant: variant as any },
          slots: { default: `${variant} button` }
        })

        expect(wrapper.classes()).toContain(`btn-${variant}`)
      })
    })

    it('accepts all valid sizes', () => {
      const sizes = ['xs', 'sm', 'md', 'lg', 'xl']

      sizes.forEach(size => {
        const wrapper = mount(Button, {
          props: { size: size as any },
          slots: { default: `${size} button` }
        })

        expect(wrapper.classes()).toContain(`btn-${size}`)
      })
    })

    it('accepts valid button types', () => {
      const types = ['button', 'submit', 'reset']

      types.forEach(type => {
        const wrapper = mount(Button, {
          props: { type: type as any },
          slots: { default: `${type} button` }
        })

        expect(wrapper.find('button').attributes('type')).toBe(type)
      })
    })
  })

  describe('Performance', () => {
    it('renders quickly', () => {
      const startTime = performance.now()

      mount(Button, {
        slots: { default: 'Performance Test' }
      })

      const endTime = performance.now()
      const renderTime = endTime - startTime

      // Should render in under 16ms (60fps)
      expect(renderTime).toBeLessThan(16)
    })

    it('handles multiple instances efficiently', () => {
      const startTime = performance.now()

      // Mount multiple button instances
      for (let i = 0; i < 100; i++) {
        const wrapper = mount(Button, {
          slots: { default: `Button ${i}` }
        })
        wrapper.unmount()
      }

      const endTime = performance.now()
      const totalTime = endTime - startTime

      // Should handle 100 instances in under 100ms
      expect(totalTime).toBeLessThan(100)
    })
  })
})

Storybook Configuration (Nuxt 3)

File: .storybook/main.ts

import type { StorybookConfig } from '@storybook/vue3-vite'
import { mergeConfig } from 'vite'

const config: StorybookConfig = {
  stories: [
    '../components/**/*.stories.@(js|jsx|mjs|ts|tsx|vue)',
    '../pages/**/*.stories.@(js|jsx|mjs|ts|tsx|vue)',
    '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx|vue)'
  ],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y'
  ],
  framework: {
    name: '@storybook/vue3-vite',
    options: {}
  },
  docs: {
    autodocs: 'tag'
  },
  typescript: {
    check: false,
    reactDocgen: 'react-docgen-typescript',
    reactDocgenTypescriptOptions: {
      shouldExtractLiteralValuesFromEnum: true,
      propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
    },
  },
  viteFinal: async (config) => {
    return mergeConfig(config, {
      define: {
        __VUE_OPTIONS_API__: true,
        __VUE_PROD_DEVTOOLS__: false
      },
      resolve: {
        alias: {
          '~': new URL('../', import.meta.url).pathname,
          '@': new URL('../', import.meta.url).pathname,
          '~~': new URL('../', import.meta.url).pathname,
          '@@': new URL('../', import.meta.url).pathname
        }
      }
    })
  }
}

export default config

File: .storybook/preview.ts

import type { Preview } from '@storybook/vue3'
import '../assets/css/main.css'

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
    docs: {
      toc: true,
    },
    a11y: {
      config: {
        rules: [
          {
            id: 'color-contrast',
            enabled: true,
          },
          {
            id: 'focus-trap',
            enabled: true,
          },
        ],
      },
    },
    backgrounds: {
      default: 'light',
      values: [
        {
          name: 'light',
          value: '#ffffff',
        },
        {
          name: 'dark',
          value: '#1a1a1a',
        },
        {
          name: 'gray',
          value: '#f5f5f5',
        },
      ],
    },
    viewport: {
      viewports: {
        mobile: {
          name: 'Mobile',
          styles: { width: '375px', height: '667px' },
        },
        tablet: {
          name: 'Tablet',
          styles: { width: '768px', height: '1024px' },
        },
        desktop: {
          name: 'Desktop',
          styles: { width: '1200px', height: '800px' },
        },
      },
    },
  },
  tags: ['autodocs'],
}

export default preview

File: components/Button.stories.ts

import type { Meta, StoryObj } from '@storybook/vue3'
import { PhCheckCircle, PhDownload, PhArrowRight } from '@phosphor-icons/vue'
import Button from './Button.vue'

const meta: Meta<typeof Button> = {
  title: 'Elements/Button',
  component: Button,
  parameters: {
    layout: 'centered',
    docs: {
      description: {
        component: 'Interactive button element built with Vue 3 and Nuxt 3.',
      },
    },
  },
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'accent', 'ghost', 'outline'],
    },
    size: {
      control: 'select',
      options: ['xs', 'sm', 'md', 'lg', 'xl'],
    },
    disabled: {
      control: 'boolean',
    },
    loading: {
      control: 'boolean',
    },
    onClick: { action: 'clicked' },
  },
  args: {
    onClick: () => console.log('Button clicked!'),
  },
}

export default meta
type Story = StoryObj<typeof meta>

export const Primary: Story = {
  args: {
    variant: 'primary',
  },
  render: (args) => ({
    components: { Button },
    setup() {
      return { args }
    },
    template: '<Button v-bind="args">Primary Button</Button>',
  }),
}

export const Secondary: Story = {
  args: {
    variant: 'secondary',
  },
  render: (args) => ({
    components: { Button },
    setup() {
      return { args }
    },
    template: '<Button v-bind="args">Secondary Button</Button>',
  }),
}

export const WithIcon: Story = {
  args: {
    variant: 'primary',
    icon: PhCheckCircle,
  },
  render: (args) => ({
    components: { Button },
    setup() {
      return { args }
    },
    template: '<Button v-bind="args">With Icon</Button>',
  }),
}

export const Loading: Story = {
  args: {
    variant: 'primary',
    loading: true,
  },
  render: (args) => ({
    components: { Button },
    setup() {
      return { args }
    },
    template: '<Button v-bind="args">Loading...</Button>',
  }),
}

export const Disabled: Story = {
  args: {
    variant: 'primary',
    disabled: true,
  },
  render: (args) => ({
    components: { Button },
    setup() {
      return { args }
    },
    template: '<Button v-bind="args">Disabled Button</Button>',
  }),
}

export const Sizes: Story = {
  render: () => ({
    components: { Button },
    template: `
      <div class="flex items-center gap-4">
        <Button size="xs">Extra Small</Button>
        <Button size="sm">Small</Button>
        <Button size="md">Medium</Button>
        <Button size="lg">Large</Button>
        <Button size="xl">Extra Large</Button>
      </div>
    `,
  }),
}

export const Variants: Story = {
  render: () => ({
    components: { Button },
    template: `
      <div class="flex items-center gap-4 flex-wrap">
        <Button variant="primary">Primary</Button>
        <Button variant="secondary">Secondary</Button>
        <Button variant="accent">Accent</Button>
        <Button variant="ghost">Ghost</Button>
        <Button variant="outline">Outline</Button>
      </div>
    `,
  }),
}

export const Interactive: Story = {
  render: () => ({
    components: { Button, PhDownload, PhArrowRight },
    setup() {
      const handleDownload = () => alert('Download started!')
      const handleLearnMore = () => alert('Navigation triggered!')
      return { handleDownload, handleLearnMore, PhDownload, PhArrowRight }
    },
    template: `
      <div class="space-y-4">
        <div class="flex gap-4">
          <Button
            variant="primary"
            :icon="PhDownload"
            @click="handleDownload"
          >
            Download File
          </Button>
          <Button
            variant="outline"
            :icon="PhArrowRight"
            @click="handleLearnMore"
          >
            Learn More
          </Button>
        </div>
      </div>
    `,
  }),
}

export const AccessibilityTest: Story = {
  args: {
    variant: 'primary',
    ariaLabel: 'This button demonstrates accessibility features',
  },
  render: (args) => ({
    components: { Button },
    setup() {
      return { args }
    },
    template: '<Button v-bind="args">Accessible Button</Button>',
  }),
  parameters: {
    a11y: {
      config: {
        rules: [
          {
            id: 'color-contrast',
            enabled: true,
          },
        ],
      },
    },
  },
}

Playwright E2E Configuration (Nuxt 3)

File: playwright.config.ts

import { defineConfig, devices