/** * M9-5 E2E #1 — Firmware Upgrade Happy Path * * 目的:驗證 KDP1 → KDP2 完整 4-stage 升級流程的 UI lifecycle。 * * 涵蓋 AC: * - AC-FW-1.2 progress modal stage 序列 + 不可中斷警告 * - AC-FW-1.3 升級成功後 modal 顯示綠勾 + auto-close + toast + Devices refresh * - AC-FW-1.5 upgrade 期間 device 鎖住其他操作(這裡 verify modal isInProgress flag) * * 涵蓋功能: * - confirm modal → upgrading → success 整條 phase 轉換 * - WS event handle(preparing/loading/flashing/verifying/done) * - 4-stage 路徑(isLegacyUpgrade=true)vs 3-stage(false)對比 * * 不在範圍(其他 spec 覆蓋): * - R-FW-11 modal 不可關(firmware-r-fw-11-modal-not-closable.spec.ts) * - 失敗注入(firmware-upgrade-error-recovery.spec.ts) * - Wails OnBeforeClose(wails-onbeforeclose-firmware-active.spec.ts) * * 執行方式: * - 此檔放 .autoflow/06-testing/scripts/ 是 spec doc 兼參考實作 * - 真要跑:複製到 frontend/src/tests/e2e/、用 pnpm test 跑 vitest * - 或:使用者下週實機驗證時對照本 spec 的 step 跑人工 checklist * * 設計取捨: * - 不用 Playwright/Cypress(visionA-local frontend 沒裝、引入新 framework * 是 architect 決策、testing 不擅自) * - 用 vitest + RTL + jsdom + mock global WebSocket + mock fetch * - 走真實 component build(不 stub firmware-upgrade-dialog)、驗 lifecycle * * Owner: Testing Agent (M9-5) * Last reviewed: 2026-05-25 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { FirmwareUpgradeDialog } from '@/components/firmware/firmware-upgrade-dialog'; import { useFirmwareStore } from '@/stores/firmware-store'; import type { Device, FirmwareProgressEvent } from '@/types/device'; // ────────────────────────────────────────────────────────────────────── // Test fixtures (Factory pattern) // ────────────────────────────────────────────────────────────────────── function makeLegacyKL520(overrides: Partial = {}): Device { return { id: 'kl520-0', name: 'KL520 #1', type: 'kneron_kl520', port: '/dev/usb0', status: 'connected', firmwareVersion: 'KDP1', firmwareIsLegacy: true, firmwareCanUpgrade: true, bundledFirmwareVersion: 'v2.2.0', ...overrides, }; } function makeKDP2KL520(overrides: Partial = {}): Device { return { id: 'kl520-0', name: 'KL520 #1', type: 'kneron_kl520', port: '/dev/usb0', status: 'connected', firmwareVersion: 'v2.1.0', firmwareIsLegacy: false, firmwareCanUpgrade: true, bundledFirmwareVersion: 'v2.2.0', ...overrides, }; } function makeProgressEvent( overrides: Partial = {}, ): FirmwareProgressEvent { return { type: 'firmware_progress', deviceId: 'kl520-0', stage: 'preparing', percent: 5, elapsedMs: 1000, direction: 'upgrade', ...overrides, }; } // ────────────────────────────────────────────────────────────────────── // Mock WS + fetch helpers // ────────────────────────────────────────────────────────────────────── /** MockWebSocket — 模擬 global WebSocket 給 createWebSocket() 用 */ class MockWebSocket { static instances: MockWebSocket[] = []; static OPEN = 1; static CLOSED = 3; readyState: number = 0; onopen: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; onclose: ((e: CloseEvent) => void) | null = null; onerror: ((e: Event) => void) | null = null; sentMessages: string[] = []; constructor(public url: string) { MockWebSocket.instances.push(this); // 模擬立即 open queueMicrotask(() => { this.readyState = MockWebSocket.OPEN; this.onopen?.(new Event('open')); }); } send(data: string) { this.sentMessages.push(data); } close() { this.readyState = MockWebSocket.CLOSED; this.onclose?.(new CloseEvent('close')); } /** Test helper:推一個 message event 給訂閱者 */ pushMessage(payload: unknown) { this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent); } } function installMockWebSocket() { MockWebSocket.instances = []; (globalThis as any).WebSocket = MockWebSocket; } function getActiveMockWS(): MockWebSocket { const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]; if (!ws) throw new Error('no mock WebSocket instance'); return ws; } function installMockFetch() { // POST /api/devices/kl520-0/firmware/upgrade → 202 {success:true, data:{taskId}} global.fetch = vi.fn().mockImplementation(async (url: string, opts?: RequestInit) => { if (url.includes('/devices/') && url.includes('/firmware/upgrade')) { return { ok: true, json: async () => ({ success: true, data: { taskId: 'task-upgrade-kl520-0-001' }, }), }; } // 預設:404 return { ok: false, json: async () => ({ success: false, error: { code: 'NOT_FOUND', message: 'unknown path' } }) }; }); } // ────────────────────────────────────────────────────────────────────── // Mock device-store fetchDevices (called by dialog 在 success effect 內) // ────────────────────────────────────────────────────────────────────── vi.mock('@/stores/device-store', () => ({ useDeviceStore: Object.assign( (selector: (s: any) => any) => selector({ fetchDevices: vi.fn() }), { getState: () => ({ fetchDevices: vi.fn() }), }, ), })); // ────────────────────────────────────────────────────────────────────── // Tests // ────────────────────────────────────────────────────────────────────── describe('M9-5 E2E #1: KDP1 → KDP2 4-stage happy path', () => { beforeEach(() => { installMockWebSocket(); installMockFetch(); useFirmwareStore.setState({ activeDeviceId: null, activeTaskId: null, phase: 'idle', progress: null, beforeVersion: null, targetVersion: null, startedAt: null, }); }); afterEach(() => { vi.restoreAllMocks(); }); it('AC-FW-1.2: confirm → upgrading 4-stage 序列正確(preparing→loading→flashing→verifying→done)', async () => { const device = makeLegacyKL520(); const onOpenChange = vi.fn(); render(); // Phase 1: confirm modal expect(useFirmwareStore.getState().phase).toBe('confirming'); expect(screen.getByText(/升級韌體|Upgrade firmware/)).toBeTruthy(); // Phase 2: 點「開始升級」 const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ }); await act(async () => { fireEvent.click(startBtn); // 等 WS connect (microtask) + fetch resolve await Promise.resolve(); await Promise.resolve(); }); await waitFor(() => { expect(useFirmwareStore.getState().phase).toBe('upgrading'); }); // Phase 3: backend 推 4 個 stage events const ws = getActiveMockWS(); const stages: Array<{ stage: FirmwareProgressEvent['stage']; percent: number }> = [ { stage: 'preparing', percent: 5 }, { stage: 'loading', percent: 20 }, { stage: 'flashing', percent: 50 }, { stage: 'verifying', percent: 90 }, ]; for (const { stage, percent } of stages) { act(() => { ws.pushMessage(makeProgressEvent({ stage, percent, elapsedMs: percent * 100 })); }); await waitFor(() => { expect(useFirmwareStore.getState().progress?.stage).toBe(stage); }); // store phase 維持 upgrading expect(useFirmwareStore.getState().phase).toBe('upgrading'); } // Phase 4: 推 done act(() => { ws.pushMessage( makeProgressEvent({ stage: 'done', percent: 100, elapsedMs: 28000, afterVersion: 'v2.2.0', }), ); }); await waitFor(() => { expect(useFirmwareStore.getState().phase).toBe('success'); }); expect(useFirmwareStore.getState().progress?.afterVersion).toBe('v2.2.0'); }); it('AC-FW-1.3: 升級成功後 1.5 秒 modal 自動關閉 + onOpenChange(false) 被叫', async () => { vi.useFakeTimers(); const device = makeLegacyKL520(); const onOpenChange = vi.fn(); render(); const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ }); await act(async () => { fireEvent.click(startBtn); await Promise.resolve(); await Promise.resolve(); }); const ws = getActiveMockWS(); act(() => { ws.pushMessage( makeProgressEvent({ stage: 'done', percent: 100, elapsedMs: 28000, afterVersion: 'v2.2.0' }), ); }); // 1.5 秒 timer await act(async () => { vi.advanceTimersByTime(1500); }); expect(onOpenChange).toHaveBeenCalledWith(false); vi.useRealTimers(); }); }); describe('M9-5 E2E #1b: KDP2 short-circuit 3-stage path', () => { beforeEach(() => { installMockWebSocket(); installMockFetch(); useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null }); }); it('isLegacyUpgrade=false 時、stageOrdinal 算出 3-stage 路徑(preparing→flashing→verifying)', async () => { const device = makeKDP2KL520(); // firmwareIsLegacy=false render( {}} />); const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ }); await act(async () => { fireEvent.click(startBtn); await Promise.resolve(); await Promise.resolve(); }); const ws = getActiveMockWS(); // KDP2 short-circuit 不走 loading stage // 推 preparing → flashing → verifying → done for (const stage of ['preparing', 'flashing', 'verifying', 'done'] as const) { act(() => { ws.pushMessage(makeProgressEvent({ stage, percent: stage === 'done' ? 100 : 50 })); }); await waitFor(() => { expect(useFirmwareStore.getState().progress?.stage).toBe(stage); }); } expect(useFirmwareStore.getState().phase).toBe('success'); }); }); describe('M9-5 E2E #1c: Per-device isolation(多裝置同時不互相干擾)', () => { beforeEach(() => { installMockWebSocket(); installMockFetch(); useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null }); }); it('activeDeviceId=A 時、device B 的 progress event 不影響 A 的 store', async () => { const deviceA = makeLegacyKL520({ id: 'kl520-A', name: 'KL520 #A' }); render( {}} />); const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ }); await act(async () => { fireEvent.click(startBtn); await Promise.resolve(); await Promise.resolve(); }); // 直接呼 store.handleEvent 模擬 device B 的事件混進來 act(() => { useFirmwareStore.getState().handleEvent({ type: 'firmware_progress', deviceId: 'kl720-B', // ← 不是 active device stage: 'error', percent: -1, elapsedMs: 1000, reason: 'connect_failed', }); }); // store 不該被 device B 的 error event 影響 expect(useFirmwareStore.getState().phase).toBe('upgrading'); expect(useFirmwareStore.getState().activeDeviceId).toBe('kl520-A'); }); });