Ver código fonte

chore: enhance test infrastructure based on pwtk-admin-web

- Add vitest.setup.ts with global mocks (localStorage, matchMedia, ResizeObserver, etc.)
- Update vitest.config.ts with setupFiles and coverage thresholds
- Enhance playwright.config.ts with debug mode support and video recording
- Add E2E API handlers for mocking API responses in e2e tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
yb 3 semanas atrás
pai
commit
4988e08825
4 arquivos alterados com 277 adições e 7 exclusões
  1. 8 4
      playwright.config.ts
  2. 162 0
      tests/e2e/mocks/api-handlers.ts
  3. 16 3
      vitest.config.ts
  4. 91 0
      vitest.setup.ts

+ 8 - 4
playwright.config.ts

@@ -1,16 +1,19 @@
 import { defineConfig, devices } from '@playwright/test'
 
+const isDebug = !!process.env.PWDEBUG || !!process.env.DEBUG
+
 export default defineConfig({
   testDir: './tests/e2e',
-  fullyParallel: true,
+  fullyParallel: isDebug ? false : true,
   forbidOnly: !!process.env.CI,
   retries: process.env.CI ? 2 : 0,
-  workers: process.env.CI ? 1 : undefined,
+  workers: process.env.CI ? 1 : isDebug ? 1 : undefined,
   reporter: 'html',
   use: {
     baseURL: 'http://localhost:3000',
     trace: 'on-first-retry',
-    screenshot: 'only-on-failure'
+    screenshot: 'only-on-failure',
+    video: 'retain-on-failure'
   },
   projects: [
     {
@@ -21,6 +24,7 @@ export default defineConfig({
   webServer: {
     command: 'pnpm run dev',
     url: 'http://localhost:3000',
-    reuseExistingServer: !process.env.CI
+    reuseExistingServer: !process.env.CI,
+    timeout: 120 * 1000
   }
 })

+ 162 - 0
tests/e2e/mocks/api-handlers.ts

@@ -0,0 +1,162 @@
+import type { Page } from '@playwright/test'
+import { mockLoginResponse, mockAdminInfo, mockMachines, mockCameras, mockDashboardStats, wrapResponse } from '../../fixtures'
+
+/**
+ * Mock login API responses
+ * POST /admin/auth/login
+ */
+export async function mockLoginAPI(page: Page) {
+  await page.route('**/admin/auth/login', async (route) => {
+    const request = route.request()
+
+    if (request.method() !== 'POST') {
+      await route.continue()
+      return
+    }
+
+    const body = request.postDataJSON()
+
+    if (body?.username === 'admin' && body?.password === 'admin123') {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify(wrapResponse(mockLoginResponse))
+      })
+    } else {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({
+          code: 401,
+          message: '用户名或密码错误',
+          data: null,
+          timestamp: Date.now()
+        })
+      })
+    }
+  })
+}
+
+/**
+ * Mock logout API responses
+ * POST /admin/auth/logout
+ */
+export async function mockLogoutAPI(page: Page) {
+  await page.route('**/admin/auth/logout', async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: 'application/json',
+      body: JSON.stringify(wrapResponse(null))
+    })
+  })
+}
+
+/**
+ * Mock admin info API responses
+ * GET /admin/auth/info
+ */
+export async function mockAdminInfoAPI(page: Page) {
+  await page.route('**/admin/auth/info', async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: 'application/json',
+      body: JSON.stringify(wrapResponse(mockAdminInfo))
+    })
+  })
+}
+
+/**
+ * Mock machines list API responses
+ * GET /admin/machines/list
+ */
+export async function mockMachinesListAPI(page: Page) {
+  await page.route('**/admin/machines/list', async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: 'application/json',
+      body: JSON.stringify(wrapResponse(mockMachines))
+    })
+  })
+}
+
+/**
+ * Mock machine detail API responses
+ * GET /admin/machines/detail
+ */
+export async function mockMachineDetailAPI(page: Page) {
+  await page.route('**/admin/machines/detail**', async (route) => {
+    const url = new URL(route.request().url())
+    const id = parseInt(url.searchParams.get('id') || '1')
+    const machine = mockMachines.find((m) => m.id === id) || mockMachines[0]
+    await route.fulfill({
+      status: 200,
+      contentType: 'application/json',
+      body: JSON.stringify(wrapResponse(machine))
+    })
+  })
+}
+
+/**
+ * Mock cameras list API responses
+ * GET /admin/cameras/list
+ */
+export async function mockCamerasListAPI(page: Page) {
+  await page.route('**/admin/cameras/list', async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: 'application/json',
+      body: JSON.stringify(wrapResponse(mockCameras))
+    })
+  })
+}
+
+/**
+ * Mock camera list API (controller)
+ * GET /camera/list
+ */
+export async function mockCameraListAPI(page: Page) {
+  await page.route('**/camera/list', async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: 'application/json',
+      body: JSON.stringify(wrapResponse(mockCameras))
+    })
+  })
+}
+
+/**
+ * Mock dashboard stats API responses
+ * GET /admin/stats/dashboard
+ */
+export async function mockDashboardStatsAPI(page: Page) {
+  await page.route('**/admin/stats/dashboard', async (route) => {
+    await route.fulfill({
+      status: 200,
+      contentType: 'application/json',
+      body: JSON.stringify(wrapResponse(mockDashboardStats))
+    })
+  })
+}
+
+/**
+ * Setup all common API mocks for authenticated pages
+ */
+export async function setupAuthenticatedMocks(page: Page) {
+  await Promise.all([
+    mockAdminInfoAPI(page),
+    mockLogoutAPI(page),
+    mockMachinesListAPI(page),
+    mockMachineDetailAPI(page),
+    mockCamerasListAPI(page),
+    mockCameraListAPI(page),
+    mockDashboardStatsAPI(page)
+  ])
+}
+
+/**
+ * Setup all API mocks including login
+ */
+export async function setupAllMocks(page: Page) {
+  await mockLoginAPI(page)
+  await setupAuthenticatedMocks(page)
+}

+ 16 - 3
vitest.config.ts

@@ -1,24 +1,37 @@
+import { fileURLToPath, URL } from 'node:url'
 import { defineConfig } from 'vitest/config'
 import vue from '@vitejs/plugin-vue'
-import { resolve } from 'path'
 
 export default defineConfig({
   plugins: [vue()],
   resolve: {
     alias: {
-      '@': resolve(__dirname, 'src')
+      '@': fileURLToPath(new URL('./src', import.meta.url))
     }
   },
   test: {
     environment: 'happy-dom',
     include: ['tests/unit/**/*.{test,spec}.ts'],
     globals: true,
+    setupFiles: ['./vitest.setup.ts'],
     coverage: {
       provider: 'v8',
       reporter: ['text', 'text-summary', 'html', 'lcov'],
       reportsDirectory: './coverage',
       include: ['src/**/*.ts', 'src/**/*.vue'],
-      exclude: ['src/main.ts', 'src/env.d.ts', 'src/**/*.d.ts']
+      exclude: [
+        'src/main.ts',
+        'src/env.d.ts',
+        'src/**/*.d.ts',
+        'src/types/**'
+      ],
+      // Coverage thresholds - can be gradually increased
+      thresholds: {
+        statements: 5,
+        branches: 5,
+        functions: 3,
+        lines: 5
+      }
     }
   }
 })

+ 91 - 0
vitest.setup.ts

@@ -0,0 +1,91 @@
+/**
+ * Vitest Setup File
+ * Global mocks and configurations for unit tests
+ */
+
+import { vi } from 'vitest'
+
+// Mock localStorage for tests
+const localStorageMock = {
+  store: {} as Record<string, string>,
+  getItem(key: string) {
+    return this.store[key] || null
+  },
+  setItem(key: string, value: string) {
+    this.store[key] = value
+  },
+  removeItem(key: string) {
+    delete this.store[key]
+  },
+  clear() {
+    this.store = {}
+  }
+}
+
+Object.defineProperty(globalThis, 'localStorage', {
+  value: localStorageMock
+})
+
+// Mock sessionStorage
+Object.defineProperty(globalThis, 'sessionStorage', {
+  value: localStorageMock
+})
+
+// Mock window.matchMedia
+Object.defineProperty(globalThis, '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()
+  }))
+})
+
+// Mock ResizeObserver
+globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
+  observe: vi.fn(),
+  unobserve: vi.fn(),
+  disconnect: vi.fn()
+}))
+
+// Mock IntersectionObserver
+globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({
+  observe: vi.fn(),
+  unobserve: vi.fn(),
+  disconnect: vi.fn()
+}))
+
+// Mock window.scrollTo
+globalThis.scrollTo = vi.fn()
+
+// Mock HTMLMediaElement methods
+globalThis.HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined)
+globalThis.HTMLMediaElement.prototype.pause = vi.fn()
+globalThis.HTMLMediaElement.prototype.load = vi.fn()
+
+// Mock canvas context for video screenshots
+HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
+  drawImage: vi.fn(),
+  fillRect: vi.fn(),
+  clearRect: vi.fn(),
+  getImageData: vi.fn(),
+  putImageData: vi.fn(),
+  createImageData: vi.fn()
+})
+
+HTMLCanvasElement.prototype.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,mock')
+
+// Mock URL.createObjectURL
+globalThis.URL.createObjectURL = vi.fn().mockReturnValue('blob:mock-url')
+globalThis.URL.revokeObjectURL = vi.fn()
+
+// Clean up after each test
+afterEach(() => {
+  localStorageMock.clear()
+  vi.clearAllMocks()
+})