iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 29

Day 29: 30天打造SaaS產品前端篇-完整系統整合與端到端測試

  • 分享至 

  • xImage
  •  

前情提要

在過去的 Day 24-28 中,我們完成了 Kyo System 前端的核心功能:

  • Day 24: 建立 React + Vite + TypeScript 專案架構
  • Day 25: 實作完整的狀態管理與 API 整合
  • Day 26: 打造企業級表單與資料驗證系統
  • Day 27: 實作即時通知與 Socket.io 前端整合
  • Day 28: 完成 Dashboard 儀表板與資料視覺化

今天是鐵人賽的倒數第二天,我們將把所有的拼圖整合在一起,完成:

  1. Socket.io 後端實作(補充 Day 27 缺少的伺服器端)
  2. 完整系統架構回顧
  3. 前端整合測試策略
  4. E2E 測試與 Playwright
  5. 效能測試與無障礙測試
  6. 生產環境部署前檢查清單

讓我們將 Kyo System 前端推向生產就緒狀態!

完整系統架構圖

┌─────────────────────────────────────────────────────────────────┐
│                         Kyo Dashboard (React)                    │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │ Auth Module  │  │ OTP Module   │  │ Notification │         │
│  │ - Login      │  │ - Send OTP   │  │ - Real-time  │         │
│  │ - JWT Store  │  │ - Verify     │  │ - Toast      │         │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘         │
│         │                  │                  │                  │
│  ┌──────▼──────────────────▼──────────────────▼───────┐         │
│  │         Zustand Store + React Query                │         │
│  └──────┬──────────────────┬──────────────────┬───────┘         │
└─────────┼──────────────────┼──────────────────┼─────────────────┘
          │                  │                  │
          │ HTTP (REST)      │                  │ WebSocket
          ▼                  ▼                  ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Fastify API Gateway                           │
├─────────────────────────────────────────────────────────────────┤
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐         │
│  │ Auth Routes  │  │ OTP Routes   │  │ Socket.io    │         │
│  │ /api/auth/*  │  │ /api/otp/*   │  │ Server       │         │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘         │
│         │                  │                  │                  │
│  ┌──────▼──────────────────▼──────────────────▼───────┐         │
│  │         JWT Middleware + Rate Limiting             │         │
│  └──────┬──────────────────┬──────────────────┬───────┘         │
└─────────┼──────────────────┼──────────────────┼─────────────────┘
          │                  │                  │
          ▼                  ▼                  ▼
┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│   PostgreSQL    │  │   Redis Cache   │  │   Redis Pub/Sub │
│   - Users       │  │   - Rate Limit  │  │   - Events      │
│   - OTP Logs    │  │   - Sessions    │  │   - Notify      │
└─────────────────┘  └─────────────────┘  └─────────────────┘

Socket.io 後端實作(Day 27 補充)

在 Day 27 中,我們完成了前端的 Socket.io 整合,現在讓我們完成後端的實作。

1. Fastify + Socket.io Server 設定

首先,安裝必要的依賴:

pnpm add socket.io @fastify/cors
pnpm add -D @types/socket.io

建立 Socket.io Server 設定檔:

// apps/kyo-otp-service/src/plugins/socket.ts
import { FastifyInstance } from 'fastify';
import { Server as SocketIOServer } from 'socket.io';
import fp from 'fastify-plugin';
import { verifyToken } from '../utils/jwt';
import type { JWTPayload } from '@kyong/kyo-types';

interface SocketData {
  userId: string;
  tenantId: string;
  email: string;
}

interface ServerToClientEvents {
  notification: (data: NotificationPayload) => void;
  'otp:sent': (data: OTPSentPayload) => void;
  'otp:verified': (data: OTPVerifiedPayload) => void;
  'rate-limit:exceeded': (data: RateLimitPayload) => void;
  system: (data: SystemMessagePayload) => void;
}

interface ClientToServerEvents {
  subscribe: (room: string) => void;
  unsubscribe: (room: string) => void;
  ping: () => void;
}

interface NotificationPayload {
  id: string;
  type: 'info' | 'success' | 'warning' | 'error';
  title: string;
  message: string;
  timestamp: string;
  action?: {
    label: string;
    url: string;
  };
}

interface OTPSentPayload {
  requestId: string;
  phoneNumber: string;
  expiresAt: string;
}

interface OTPVerifiedPayload {
  requestId: string;
  phoneNumber: string;
  verifiedAt: string;
}

interface RateLimitPayload {
  endpoint: string;
  limit: number;
  resetAt: string;
}

interface SystemMessagePayload {
  message: string;
  level: 'info' | 'warning' | 'error';
}

declare module 'fastify' {
  interface FastifyInstance {
    io: SocketIOServer<
      ClientToServerEvents,
      ServerToClientEvents,
      {},
      SocketData
    >;
  }
}

async function socketPlugin(fastify: FastifyInstance) {
  const io = new SocketIOServer<
    ClientToServerEvents,
    ServerToClientEvents,
    {},
    SocketData
  >(fastify.server, {
    cors: {
      origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
      credentials: true,
    },
    path: '/socket.io',
    transports: ['websocket', 'polling'],
    pingTimeout: 60000,
    pingInterval: 25000,
  });

  // JWT Authentication Middleware
  io.use(async (socket, next) => {
    try {
      const token = socket.handshake.auth.token as string;

      if (!token) {
        return next(new Error('Authentication token required'));
      }

      // Verify JWT
      const payload = await verifyToken(token) as JWTPayload;

      if (!payload || !payload.sub) {
        return next(new Error('Invalid token'));
      }

      // Attach user data to socket
      socket.data = {
        userId: payload.sub,
        tenantId: payload.tenant_id,
        email: payload.email,
      };

      next();
    } catch (error) {
      fastify.log.error({ error }, 'Socket authentication failed');
      next(new Error('Authentication failed'));
    }
  });

  // Connection Handler
  io.on('connection', (socket) => {
    const { userId, tenantId, email } = socket.data;

    fastify.log.info(
      { userId, tenantId, socketId: socket.id },
      'Client connected'
    );

    // Auto-join user's personal room
    const userRoom = `user:${userId}`;
    const tenantRoom = `tenant:${tenantId}`;

    socket.join(userRoom);
    socket.join(tenantRoom);

    // Send welcome notification
    socket.emit('notification', {
      id: `welcome-${Date.now()}`,
      type: 'info',
      title: '連線成功',
      message: '即時通知已啟用',
      timestamp: new Date().toISOString(),
    });

    // Subscribe to custom rooms
    socket.on('subscribe', (room: string) => {
      // Validate room format (tenant:xxx or user:xxx)
      if (
        room.startsWith(`tenant:${tenantId}`) ||
        room.startsWith(`user:${userId}`)
      ) {
        socket.join(room);
        fastify.log.info({ userId, room }, 'Subscribed to room');

        socket.emit('system', {
          message: `已訂閱房間: ${room}`,
          level: 'info',
        });
      } else {
        socket.emit('system', {
          message: '無權限訂閱此房間',
          level: 'error',
        });
      }
    });

    // Unsubscribe from rooms
    socket.on('unsubscribe', (room: string) => {
      socket.leave(room);
      fastify.log.info({ userId, room }, 'Unsubscribed from room');
    });

    // Ping/Pong for connection health
    socket.on('ping', () => {
      socket.emit('system', {
        message: 'pong',
        level: 'info',
      });
    });

    // Disconnection Handler
    socket.on('disconnect', (reason) => {
      fastify.log.info(
        { userId, socketId: socket.id, reason },
        'Client disconnected'
      );
    });

    // Error Handler
    socket.on('error', (error) => {
      fastify.log.error(
        { userId, socketId: socket.id, error },
        'Socket error'
      );
    });
  });

  // Decorate Fastify instance
  fastify.decorate('io', io);

  // Graceful shutdown
  fastify.addHook('onClose', async () => {
    io.close();
  });
}

export default fp(socketPlugin, {
  name: 'socket-io',
});

2. Notification Service Integration

建立通知服務,整合 Socket.io 與 Redis Pub/Sub:

// packages/kyo-core/src/services/notification.service.ts
import { Redis } from 'ioredis';
import type { Server as SocketIOServer } from 'socket.io';
import type {
  ServerToClientEvents,
  ClientToServerEvents,
  SocketData,
} from '../types/socket';

export interface NotificationMessage {
  userId?: string;
  tenantId?: string;
  room?: string;
  event: keyof ServerToClientEvents;
  data: any;
}

export class NotificationService {
  private publisher: Redis;
  private subscriber: Redis;
  private io?: SocketIOServer<ClientToServerEvents, ServerToClientEvents, {}, SocketData>;

  constructor(redisUrl: string) {
    this.publisher = new Redis(redisUrl);
    this.subscriber = new Redis(redisUrl);
  }

  /**
   * Initialize Socket.io server and subscribe to Redis channels
   */
  async initialize(io: SocketIOServer) {
    this.io = io;

    // Subscribe to notification channel
    await this.subscriber.subscribe('notifications');

    // Handle incoming messages from Redis
    this.subscriber.on('message', (channel, message) => {
      if (channel === 'notifications') {
        try {
          const notification: NotificationMessage = JSON.parse(message);
          this.emitNotification(notification);
        } catch (error) {
          console.error('Failed to parse notification:', error);
        }
      }
    });
  }

  /**
   * Publish notification to Redis (for distributed systems)
   */
  async publish(notification: NotificationMessage): Promise<void> {
    await this.publisher.publish(
      'notifications',
      JSON.stringify(notification)
    );
  }

  /**
   * Emit notification directly to Socket.io clients
   */
  private emitNotification(notification: NotificationMessage): void {
    if (!this.io) {
      throw new Error('Socket.io server not initialized');
    }

    const { userId, tenantId, room, event, data } = notification;

    // Emit to specific room
    if (room) {
      this.io.to(room).emit(event, data);
      return;
    }

    // Emit to specific user
    if (userId) {
      this.io.to(`user:${userId}`).emit(event, data);
      return;
    }

    // Emit to tenant
    if (tenantId) {
      this.io.to(`tenant:${tenantId}`).emit(event, data);
      return;
    }

    // Broadcast to all
    this.io.emit(event, data);
  }

  /**
   * Send OTP sent notification
   */
  async notifyOTPSent(
    userId: string,
    tenantId: string,
    data: {
      requestId: string;
      phoneNumber: string;
      expiresAt: string;
    }
  ): Promise<void> {
    await this.publish({
      userId,
      tenantId,
      event: 'otp:sent',
      data,
    });

    // Also send a user notification
    await this.publish({
      userId,
      tenantId,
      event: 'notification',
      data: {
        id: `otp-sent-${data.requestId}`,
        type: 'success',
        title: 'OTP 已發送',
        message: `驗證碼已發送至 ${data.phoneNumber.replace(
          /(\d{4})\d{4}(\d{2})/,
          '$1****$2'
        )}`,
        timestamp: new Date().toISOString(),
      },
    });
  }

  /**
   * Send OTP verified notification
   */
  async notifyOTPVerified(
    userId: string,
    tenantId: string,
    data: {
      requestId: string;
      phoneNumber: string;
      verifiedAt: string;
    }
  ): Promise<void> {
    await this.publish({
      userId,
      tenantId,
      event: 'otp:verified',
      data,
    });

    await this.publish({
      userId,
      tenantId,
      event: 'notification',
      data: {
        id: `otp-verified-${data.requestId}`,
        type: 'success',
        title: '驗證成功',
        message: '手機號碼驗證完成',
        timestamp: new Date().toISOString(),
      },
    });
  }

  /**
   * Send rate limit exceeded notification
   */
  async notifyRateLimitExceeded(
    userId: string,
    tenantId: string,
    data: {
      endpoint: string;
      limit: number;
      resetAt: string;
    }
  ): Promise<void> {
    await this.publish({
      userId,
      tenantId,
      event: 'rate-limit:exceeded',
      data,
    });

    await this.publish({
      userId,
      tenantId,
      event: 'notification',
      data: {
        id: `rate-limit-${Date.now()}`,
        type: 'warning',
        title: '請求超過限制',
        message: `已達到 ${data.endpoint} 的請求限制,請稍後再試`,
        timestamp: new Date().toISOString(),
      },
    });
  }

  /**
   * Close Redis connections
   */
  async close(): Promise<void> {
    await this.publisher.quit();
    await this.subscriber.quit();
  }
}

3. 整合到 Fastify Application

將 Socket.io 與通知服務整合到主應用:

// apps/kyo-otp-service/src/app.ts
import Fastify from 'fastify';
import cors from '@fastify/cors';
import socketPlugin from './plugins/socket';
import { NotificationService } from '@kyong/kyo-core';

const app = Fastify({
  logger: {
    level: process.env.LOG_LEVEL || 'info',
  },
});

// Register CORS
await app.register(cors, {
  origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
  credentials: true,
});

// Register Socket.io
await app.register(socketPlugin);

// Initialize Notification Service
const notificationService = new NotificationService(
  process.env.REDIS_URL || 'redis://localhost:6379'
);

await notificationService.initialize(app.io);

// Make notification service available globally
app.decorate('notificationService', notificationService);

// Example: Send notification in OTP route
app.post('/api/otp/send', async (request, reply) => {
  const { phoneNumber } = request.body;
  const userId = request.user.sub;
  const tenantId = request.user.tenant_id;

  // Send OTP logic...
  const requestId = 'otp-123';
  const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();

  // Notify user via Socket.io
  await app.notificationService.notifyOTPSent(userId, tenantId, {
    requestId,
    phoneNumber,
    expiresAt,
  });

  return { success: true, requestId };
});

// Graceful shutdown
app.addHook('onClose', async () => {
  await notificationService.close();
});

export default app;

4. 前後端整合測試

建立測試檔案驗證 Socket.io 整合:

// apps/kyo-otp-service/test/integration/socket.test.ts
import { test } from 'node:test';
import assert from 'node:assert';
import { io as Client, Socket } from 'socket.io-client';
import app from '../../src/app';
import { generateToken } from '../../src/utils/jwt';

test('Socket.io Integration Tests', async (t) => {
  const address = await app.listen({ port: 0 });
  const port = app.server.address().port;
  const baseUrl = `http://localhost:${port}`;

  t.after(async () => {
    await app.close();
  });

  await t.test('should connect with valid JWT', async () => {
    const token = await generateToken({
      sub: 'user-123',
      tenant_id: 'tenant-456',
      email: 'test@example.com',
    });

    const client = Client(baseUrl, {
      auth: { token },
      transports: ['websocket'],
    });

    await new Promise<void>((resolve, reject) => {
      client.on('connect', () => {
        assert.ok(client.connected, 'Client should be connected');
        resolve();
      });

      client.on('connect_error', (error) => {
        reject(error);
      });

      setTimeout(() => reject(new Error('Connection timeout')), 5000);
    });

    client.disconnect();
  });

  await t.test('should reject connection without JWT', async () => {
    const client = Client(baseUrl, {
      transports: ['websocket'],
    });

    await new Promise<void>((resolve, reject) => {
      client.on('connect', () => {
        reject(new Error('Should not connect without token'));
      });

      client.on('connect_error', (error) => {
        assert.ok(
          error.message.includes('Authentication'),
          'Should fail with auth error'
        );
        resolve();
      });

      setTimeout(() => reject(new Error('Timeout')), 5000);
    });

    client.disconnect();
  });

  await t.test('should receive welcome notification', async () => {
    const token = await generateToken({
      sub: 'user-123',
      tenant_id: 'tenant-456',
      email: 'test@example.com',
    });

    const client = Client(baseUrl, {
      auth: { token },
      transports: ['websocket'],
    });

    await new Promise<void>((resolve, reject) => {
      client.on('notification', (data) => {
        assert.strictEqual(data.type, 'info');
        assert.strictEqual(data.title, '連線成功');
        client.disconnect();
        resolve();
      });

      setTimeout(() => reject(new Error('No notification received')), 5000);
    });
  });

  await t.test('should join and receive room messages', async () => {
    const token = await generateToken({
      sub: 'user-123',
      tenant_id: 'tenant-456',
      email: 'test@example.com',
    });

    const client = Client(baseUrl, {
      auth: { token },
      transports: ['websocket'],
    });

    await new Promise<void>((resolve, reject) => {
      client.on('connect', () => {
        // Subscribe to tenant room
        client.emit('subscribe', 'tenant:tenant-456');

        // Emit notification to tenant room
        app.notificationService.publish({
          tenantId: 'tenant-456',
          event: 'notification',
          data: {
            id: 'test-notification',
            type: 'info',
            title: 'Test',
            message: 'Test message',
            timestamp: new Date().toISOString(),
          },
        });
      });

      client.on('notification', (data) => {
        if (data.id === 'test-notification') {
          assert.strictEqual(data.title, 'Test');
          client.disconnect();
          resolve();
        }
      });

      setTimeout(() => reject(new Error('No room message received')), 5000);
    });
  });
});

前端整合測試策略

1. React Testing Library 整合測試

測試多個元件協作的場景:

// apps/kyo-dashboard/src/__tests__/integration/otp-flow.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { OTPPage } from '../../pages/OTPPage';
import { useAuthStore } from '../../stores/auth.store';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>{children}</BrowserRouter>
    </QueryClientProvider>
  );
};

describe('OTP Flow Integration', () => {
  beforeEach(() => {
    // Reset auth store
    useAuthStore.setState({
      token: 'mock-jwt-token',
      user: { id: '1', email: 'test@example.com' },
    });
  });

  it('should complete full OTP send and verify flow', async () => {
    const user = userEvent.setup();

    render(<OTPPage />, { wrapper: createWrapper() });

    // Step 1: Enter phone number
    const phoneInput = screen.getByLabelText(/手機號碼/i);
    await user.type(phoneInput, '0912345678');

    // Step 2: Click send OTP
    const sendButton = screen.getByRole('button', { name: /發送驗證碼/i });
    await user.click(sendButton);

    // Step 3: Wait for success message
    await waitFor(() => {
      expect(
        screen.getByText(/驗證碼已發送/i)
      ).toBeInTheDocument();
    });

    // Step 4: Verify button is now disabled (countdown)
    expect(sendButton).toBeDisabled();

    // Step 5: Enter OTP code
    const otpInputs = screen.getAllByLabelText(/驗證碼/i);
    await user.type(otpInputs[0], '123456');

    // Step 6: Click verify
    const verifyButton = screen.getByRole('button', { name: /驗證/i });
    await user.click(verifyButton);

    // Step 7: Wait for success
    await waitFor(() => {
      expect(screen.getByText(/驗證成功/i)).toBeInTheDocument();
    });
  });

  it('should show error when rate limit exceeded', async () => {
    const user = userEvent.setup();

    // Mock rate limit error
    server.use(
      http.post('/api/otp/send', () => {
        return HttpResponse.json(
          { error: 'Rate limit exceeded' },
          { status: 429 }
        );
      })
    );

    render(<OTPPage />, { wrapper: createWrapper() });

    const phoneInput = screen.getByLabelText(/手機號碼/i);
    await user.type(phoneInput, '0912345678');

    const sendButton = screen.getByRole('button', { name: /發送驗證碼/i });
    await user.click(sendButton);

    await waitFor(() => {
      expect(
        screen.getByText(/請求超過限制/i)
      ).toBeInTheDocument();
    });
  });

  it('should handle Socket.io real-time notifications', async () => {
    const { container } = render(<OTPPage />, { wrapper: createWrapper() });

    // Simulate Socket.io notification
    const mockNotification = {
      id: 'notif-1',
      type: 'success',
      title: 'OTP 已發送',
      message: '驗證碼已發送至您的手機',
      timestamp: new Date().toISOString(),
    };

    // Trigger notification via Socket.io mock
    const event = new CustomEvent('socket:notification', {
      detail: mockNotification,
    });
    window.dispatchEvent(event);

    await waitFor(() => {
      expect(screen.getByText(/OTP 已發送/i)).toBeInTheDocument();
    });
  });
});

2. Context Integration Testing

測試多個 Context 的協作:

// apps/kyo-dashboard/src/__tests__/integration/auth-context.test.tsx
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/auth.store';
import { useAuth } from '../../hooks/useAuth';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

describe('Auth Context Integration', () => {
  beforeEach(() => {
    useAuthStore.getState().logout();
    localStorage.clear();
  });

  it('should persist auth state across page reloads', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper(),
    });

    // Login
    await result.current.login({
      email: 'test@example.com',
      password: 'password123',
    });

    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(true);
    });

    // Simulate page reload
    const token = localStorage.getItem('auth-token');
    expect(token).toBeTruthy();

    // Create new hook instance (simulating reload)
    const { result: reloadedResult } = renderHook(() => useAuth(), {
      wrapper: createWrapper(),
    });

    await waitFor(() => {
      expect(reloadedResult.current.isAuthenticated).toBe(true);
      expect(reloadedResult.current.user?.email).toBe('test@example.com');
    });
  });

  it('should clear auth state on logout', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper(),
    });

    // Login first
    await result.current.login({
      email: 'test@example.com',
      password: 'password123',
    });

    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(true);
    });

    // Logout
    await result.current.logout();

    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(false);
      expect(result.current.user).toBeNull();
      expect(localStorage.getItem('auth-token')).toBeNull();
    });
  });

  it('should handle token expiration', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: createWrapper(),
    });

    // Login with expired token
    server.use(
      http.post('/api/auth/login', () => {
        return HttpResponse.json({
          token: 'expired-token',
          user: { id: '1', email: 'test@example.com' },
        });
      }),
      http.get('/api/auth/me', () => {
        return HttpResponse.json(
          { error: 'Token expired' },
          { status: 401 }
        );
      })
    );

    await result.current.login({
      email: 'test@example.com',
      password: 'password123',
    });

    // Try to fetch user data
    await waitFor(() => {
      expect(result.current.isAuthenticated).toBe(false);
      expect(result.current.user).toBeNull();
    });
  });
});

E2E 測試與 Playwright

1. Playwright 設定

安裝與設定 Playwright:

pnpm add -D @playwright/test
pnpm exec playwright install

建立 Playwright 設定檔:

// apps/kyo-dashboard/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results/results.json' }],
  ],
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],

  webServer: {
    command: 'pnpm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
});

2. Complete User Flow E2E Tests

// apps/kyo-dashboard/e2e/otp-flow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('OTP Complete User Flow', () => {
  test.beforeEach(async ({ page }) => {
    // Login
    await page.goto('/login');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL('/dashboard');
  });

  test('should send and verify OTP successfully', async ({ page }) => {
    // Navigate to OTP page
    await page.click('text=OTP 管理');
    await expect(page).toHaveURL('/otp');

    // Fill phone number
    await page.fill('input[name="phoneNumber"]', '0912345678');

    // Send OTP
    await page.click('button:has-text("發送驗證碼")');

    // Wait for success notification
    await expect(page.locator('.toast-success')).toContainText('驗證碼已發送');

    // Verify countdown starts
    await expect(page.locator('button:has-text("秒後重試")')).toBeVisible();

    // Enter OTP code
    const otpInputs = page.locator('input[inputmode="numeric"]');
    await otpInputs.nth(0).fill('1');
    await otpInputs.nth(1).fill('2');
    await otpInputs.nth(2).fill('3');
    await otpInputs.nth(3).fill('4');
    await otpInputs.nth(4).fill('5');
    await otpInputs.nth(5).fill('6');

    // Click verify
    await page.click('button:has-text("驗證")');

    // Wait for success
    await expect(page.locator('.toast-success')).toContainText('驗證成功');

    // Verify log entry appears
    await expect(page.locator('table tbody tr').first()).toContainText(
      '0912345678'
    );
  });

  test('should show error for invalid OTP', async ({ page }) => {
    await page.goto('/otp');

    // Send OTP
    await page.fill('input[name="phoneNumber"]', '0912345678');
    await page.click('button:has-text("發送驗證碼")');
    await expect(page.locator('.toast-success')).toBeVisible();

    // Enter wrong OTP
    const otpInputs = page.locator('input[inputmode="numeric"]');
    for (let i = 0; i < 6; i++) {
      await otpInputs.nth(i).fill('9');
    }

    await page.click('button:has-text("驗證")');

    // Should show error
    await expect(page.locator('.toast-error')).toContainText('驗證碼錯誤');
  });

  test('should handle rate limiting gracefully', async ({ page }) => {
    await page.goto('/otp');

    const phoneNumber = '0912345678';

    // Send OTP multiple times rapidly
    for (let i = 0; i < 6; i++) {
      await page.fill('input[name="phoneNumber"]', phoneNumber);
      await page.click('button:has-text("發送驗證碼")');

      if (i < 5) {
        // Wait for countdown to finish
        await page.waitForTimeout(60000);
      }
    }

    // Should show rate limit error
    await expect(page.locator('.toast-warning')).toContainText(
      '請求超過限制'
    );
  });

  test('should receive real-time notifications via Socket.io', async ({
    page,
  }) => {
    await page.goto('/otp');

    // Send OTP
    await page.fill('input[name="phoneNumber"]', '0912345678');
    await page.click('button:has-text("發送驗證碼")');

    // Wait for Socket.io notification (not just HTTP response)
    await expect(page.locator('.notification-badge')).toHaveText('1');

    // Open notification panel
    await page.click('button[aria-label="通知"]');

    // Verify notification content
    await expect(page.locator('.notification-item')).toContainText(
      'OTP 已發送'
    );
  });
});

test.describe('Dashboard E2E', () => {
  test('should display OTP statistics correctly', async ({ page }) => {
    await page.goto('/login');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    // Navigate to dashboard
    await page.goto('/dashboard');

    // Wait for charts to load
    await page.waitForSelector('canvas');

    // Verify stat cards
    await expect(page.locator('.stat-card').first()).toContainText(
      '本月發送量'
    );

    // Verify chart is rendered
    const canvas = page.locator('canvas').first();
    await expect(canvas).toBeVisible();

    // Take screenshot for visual regression
    await page.screenshot({
      path: 'test-results/dashboard-stats.png',
      fullPage: true,
    });
  });

  test('should filter logs by date range', async ({ page }) => {
    await page.goto('/login');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    await page.goto('/otp');

    // Open date picker
    await page.click('button:has-text("日期範圍")');

    // Select date range (last 7 days)
    await page.click('text=最近 7 天');

    // Wait for logs to reload
    await page.waitForResponse((resp) =>
      resp.url().includes('/api/otp/logs')
    );

    // Verify logs are filtered
    const rows = page.locator('table tbody tr');
    await expect(rows).not.toHaveCount(0);

    // Export logs
    await page.click('button:has-text("匯出")');

    // Wait for download
    const downloadPromise = page.waitForEvent('download');
    const download = await downloadPromise;
    expect(download.suggestedFilename()).toContain('otp-logs');
  });
});

3. Accessibility E2E Tests

// apps/kyo-dashboard/e2e/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Accessibility Tests', () => {
  test('should not have any automatically detectable accessibility issues', async ({
    page,
  }) => {
    await page.goto('/login');

    const accessibilityScanResults = await new AxeBuilder({ page }).analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
  });

  test('should be keyboard navigable', async ({ page }) => {
    await page.goto('/login');

    // Tab through form
    await page.keyboard.press('Tab');
    await expect(page.locator('input[name="email"]')).toBeFocused();

    await page.keyboard.press('Tab');
    await expect(page.locator('input[name="password"]')).toBeFocused();

    await page.keyboard.press('Tab');
    await expect(page.locator('button[type="submit"]')).toBeFocused();

    // Submit with Enter
    await page.keyboard.press('Enter');
  });

  test('should have proper ARIA labels', async ({ page }) => {
    await page.goto('/otp');

    // Check ARIA labels
    await expect(page.locator('[aria-label="手機號碼"]')).toBeVisible();
    await expect(page.locator('[role="alert"]')).toBeHidden();

    // Trigger error
    await page.click('button:has-text("發送驗證碼")');
    await expect(page.locator('[role="alert"]')).toBeVisible();
  });

  test('should support screen reader announcements', async ({ page }) => {
    await page.goto('/otp');

    // Check live region
    const liveRegion = page.locator('[aria-live="polite"]');
    await expect(liveRegion).toBeEmpty();

    // Trigger notification
    await page.fill('input[name="phoneNumber"]', '0912345678');
    await page.click('button:has-text("發送驗證碼")');

    // Verify announcement
    await expect(liveRegion).toContainText('驗證碼已發送');
  });
});

效能測試與優化

1. Lighthouse CI

安裝 Lighthouse CI:

pnpm add -D @lhci/cli

建立 Lighthouse 設定:

// apps/kyo-dashboard/lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: [
        'http://localhost:5173/',
        'http://localhost:5173/login',
        'http://localhost:5173/dashboard',
        'http://localhost:5173/otp',
      ],
      numberOfRuns: 3,
      settings: {
        preset: 'desktop',
      },
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'categories:accessibility': ['error', { minScore: 0.9 }],
        'categories:best-practices': ['error', { minScore: 0.9 }],
        'categories:seo': ['error', { minScore: 0.9 }],
        'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['error', { maxNumericValue: 300 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

2. Bundle Size Analysis

建立 bundle 分析腳本:

// apps/kyo-dashboard/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig(({ mode }) => ({
  plugins: [
    react(),
    mode === 'analyze' &&
      visualizer({
        filename: './dist/stats.html',
        open: true,
        gzipSize: true,
        brotliSize: true,
      }),
  ].filter(Boolean),
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'react-vendor': ['react', 'react-dom', 'react-router-dom'],
          'query-vendor': ['@tanstack/react-query'],
          'chart-vendor': ['recharts'],
          'socket-vendor': ['socket.io-client'],
        },
      },
    },
  },
}));

執行分析:

pnpm run build --mode=analyze

3. Performance Monitoring

建立效能監控 hook:

// apps/kyo-dashboard/src/hooks/usePerformanceMonitoring.ts
import { useEffect } from 'react';

interface PerformanceMetrics {
  fcp: number; // First Contentful Paint
  lcp: number; // Largest Contentful Paint
  fid: number; // First Input Delay
  cls: number; // Cumulative Layout Shift
  ttfb: number; // Time to First Byte
}

export function usePerformanceMonitoring() {
  useEffect(() => {
    if (typeof window === 'undefined' || !window.performance) {
      return;
    }

    const metrics: Partial<PerformanceMetrics> = {};

    // Measure FCP
    const fcpObserver = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const fcp = entries.find((entry) => entry.name === 'first-contentful-paint');
      if (fcp) {
        metrics.fcp = fcp.startTime;
        console.log('FCP:', fcp.startTime);
      }
    });
    fcpObserver.observe({ entryTypes: ['paint'] });

    // Measure LCP
    const lcpObserver = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1];
      metrics.lcp = lastEntry.startTime;
      console.log('LCP:', lastEntry.startTime);
    });
    lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] });

    // Measure FID
    const fidObserver = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const firstInput = entries[0] as PerformanceEventTiming;
      metrics.fid = firstInput.processingStart - firstInput.startTime;
      console.log('FID:', metrics.fid);
    });
    fidObserver.observe({ entryTypes: ['first-input'] });

    // Measure CLS
    let clsValue = 0;
    const clsObserver = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!(entry as any).hadRecentInput) {
          clsValue += (entry as any).value;
        }
      }
      metrics.cls = clsValue;
      console.log('CLS:', clsValue);
    });
    clsObserver.observe({ entryTypes: ['layout-shift'] });

    // Measure TTFB
    const navigationTiming = performance.getEntriesByType(
      'navigation'
    )[0] as PerformanceNavigationTiming;
    if (navigationTiming) {
      metrics.ttfb = navigationTiming.responseStart - navigationTiming.requestStart;
      console.log('TTFB:', metrics.ttfb);
    }

    // Send metrics to analytics (optional)
    window.addEventListener('beforeunload', () => {
      if (Object.keys(metrics).length > 0) {
        // Send to analytics service
        navigator.sendBeacon(
          '/api/analytics/performance',
          JSON.stringify(metrics)
        );
      }
    });

    return () => {
      fcpObserver.disconnect();
      lcpObserver.disconnect();
      fidObserver.disconnect();
      clsObserver.disconnect();
    };
  }, []);
}

生產環境部署前檢查清單

Frontend Deployment Checklist

## 前端部署檢查清單

### Code Quality
- [ ] 所有 ESLint 警告已解決
- [ ] 所有 TypeScript 型別錯誤已修復
- [ ] 程式碼已通過 Prettier 格式化
- [ ] 無 console.log 或除錯程式碼

### Testing
- [ ] 單元測試通過率 > 80%
- [ ] 整合測試全部通過
- [ ] E2E 測試全部通過
- [ ] A11y 測試無違規項目
- [ ] 跨瀏覽器測試完成 (Chrome, Firefox, Safari)
- [ ] 行動裝置測試完成 (iOS, Android)

### Performance
- [ ] Lighthouse Performance Score > 90
- [ ] FCP < 2s
- [ ] LCP < 2.5s
- [ ] CLS < 0.1
- [ ] Bundle size < 500KB (gzipped)
- [ ] 圖片已優化 (WebP, lazy loading)
- [ ] 字體已優化 (subset, preload)

### Security
- [ ] 所有依賴套件已更新
- [ ] 無高危安全漏洞 (npm audit)
- [ ] XSS 防護已啟用
- [ ] CSRF Token 已實作
- [ ] Content Security Policy 已設定
- [ ] HTTPS 強制轉址

### SEO & Meta
- [ ] Meta tags 完整 (title, description, og)
- [ ] robots.txt 已設定
- [ ] sitemap.xml 已產生
- [ ] Canonical URLs 正確

### Monitoring
- [ ] Error tracking 已設定 (Sentry)
- [ ] Analytics 已設定 (GA4)
- [ ] Performance monitoring 已啟用
- [ ] Log aggregation 已設定

### Environment
- [ ] 環境變數已設定 (.env.production)
- [ ] API endpoints 指向正式環境
- [ ] CDN 已設定
- [ ] Cache headers 已設定
- [ ] Gzip/Brotli 壓縮已啟用

### Documentation
- [ ] README 已更新
- [ ] API 文件已更新
- [ ] Deployment guide 已撰寫
- [ ] Rollback plan 已準備

### Post-Deployment
- [ ] Smoke tests 通過
- [ ] Health check endpoint 正常
- [ ] 監控儀表板正常
- [ ] 備份策略已測試

Automated Pre-Deployment Script

// apps/kyo-dashboard/scripts/pre-deploy.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import chalk from 'chalk';

const execAsync = promisify(exec);

interface CheckResult {
  name: string;
  passed: boolean;
  message: string;
}

const checks: CheckResult[] = [];

async function runCheck(
  name: string,
  command: string
): Promise<CheckResult> {
  console.log(chalk.blue(`Running ${name}...`));

  try {
    const { stdout, stderr } = await execAsync(command);

    if (stderr && !stderr.includes('warning')) {
      return {
        name,
        passed: false,
        message: stderr,
      };
    }

    return {
      name,
      passed: true,
      message: stdout,
    };
  } catch (error) {
    return {
      name,
      passed: false,
      message: error.message,
    };
  }
}

async function main() {
  console.log(chalk.bold.cyan('\n🚀 Pre-Deployment Checks\n'));

  // Lint
  checks.push(await runCheck('ESLint', 'pnpm run lint'));

  // Type Check
  checks.push(await runCheck('TypeScript', 'pnpm run type-check'));

  // Unit Tests
  checks.push(await runCheck('Unit Tests', 'pnpm run test:unit'));

  // Integration Tests
  checks.push(await runCheck('Integration Tests', 'pnpm run test:integration'));

  // E2E Tests
  checks.push(await runCheck('E2E Tests', 'pnpm run test:e2e'));

  // Security Audit
  checks.push(await runCheck('Security Audit', 'pnpm audit --production'));

  // Build
  checks.push(await runCheck('Production Build', 'pnpm run build'));

  // Bundle Size Check
  const { stdout } = await execAsync('du -sh dist');
  const sizeMatch = stdout.match(/(\d+\.?\d*)([KMG])/);
  const size = sizeMatch ? parseFloat(sizeMatch[1]) : 0;
  const unit = sizeMatch ? sizeMatch[2] : 'K';

  const sizeOk = unit === 'K' || (unit === 'M' && size < 5);
  checks.push({
    name: 'Bundle Size',
    passed: sizeOk,
    message: sizeOk
      ? `Bundle size: ${size}${unit} ✓`
      : `Bundle size too large: ${size}${unit}`,
  });

  // Print Results
  console.log(chalk.bold.cyan('\n📊 Check Results\n'));

  let allPassed = true;
  for (const check of checks) {
    const icon = check.passed ? chalk.green('✓') : chalk.red('✗');
    console.log(`${icon} ${check.name}`);

    if (!check.passed) {
      console.log(chalk.red(`  ${check.message}`));
      allPassed = false;
    }
  }

  console.log('\n');

  if (allPassed) {
    console.log(chalk.bold.green('✅ All checks passed! Ready to deploy.\n'));
    process.exit(0);
  } else {
    console.log(chalk.bold.red('❌ Some checks failed. Please fix before deploying.\n'));
    process.exit(1);
  }
}

main().catch((error) => {
  console.error(chalk.red('Error running pre-deployment checks:'), error);
  process.exit(1);
});

執行部署前檢查:

pnpm run pre-deploy

今日總結

今天我們完成了 Kyo System 前端的最後拼圖:

完成項目

  1. Socket.io 完整實作

    • Fastify + Socket.io Server 設定
    • JWT WebSocket 認證
    • 通知推送服務
    • Redis Pub/Sub 整合
    • 前後端整合測試
  2. 完整測試策略

    • React Testing Library 整合測試
    • Playwright E2E 測試
    • 無障礙測試 (A11y)
    • 效能測試與監控
  3. 生產就緒

    • 部署前檢查清單
    • 自動化部署腳本
    • 效能監控設定
    • Bundle 優化

技術特點

  • 完整的 WebSocket 架構:從前端到後端的即時通訊實作
  • 全方位測試覆蓋:單元、整合、E2E、A11y、效能測試
  • 自動化品質把關:Pre-deployment checks 確保程式碼品質
  • 效能優化:Bundle splitting、lazy loading、監控設定

上一篇
Day 28: 30天打造SaaS產品前端篇-即時儀表板與資料視覺化
系列文
30 天製作工作室 SaaS 產品 (前端篇)29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言