/** * M9-5 E2E #2 — Firmware Upgrade Error Recovery * * 目的:對 Design Spec §7.1 列出的 8 種失敗 reason 注入 progress event、驗 UI * 對應 friendly message + 復原行動按鈕。 * * 涵蓋 AC: * - AC-FW-1.4 升級失敗時 modal 顯示明確失敗類型 + 複製錯誤訊息 + 重試指引 * - R-FW-7 升級失敗 device 不該卡死 * - R-FW-11/12 destructive reason 必須顯示 brick warning + 不可 retry * * Reason 對應 8 種 friendly message: * 1. scan_not_found → settings.firmware.error.message.scanNotFound * 2. connect_failed → settings.firmware.error.message.connectFailed * 3. loader_write_failed → settings.firmware.error.message.loaderWriteFailed * 4. upgrade_mid_failed → settings.firmware.error.message.upgradeMidFailed * 5. verify_mismatch → settings.firmware.error.message.verifyMismatch (destructive) * 6. verify_not_found → settings.firmware.error.message.verifyNotFound (destructive) * 7. timeout → settings.firmware.error.message.timeout * 8. disconnect_during_op → settings.firmware.error.message.disconnect (destructive) * * Owner: Testing Agent (M9-5) * Last reviewed: 2026-05-25 */ import { describe, it, expect, beforeEach, 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, FirmwareReason } from '@/types/device'; // ────────────────────────────────────────────────────────────────────── // Test fixtures // ────────────────────────────────────────────────────────────────────── 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, }; } class MockWebSocket { static instances: MockWebSocket[] = []; static OPEN = 1; static CLOSED = 3; readyState = 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; constructor(public url: string) { MockWebSocket.instances.push(this); queueMicrotask(() => { this.readyState = MockWebSocket.OPEN; this.onopen?.(new Event('open')); }); } send() {} close() { this.readyState = MockWebSocket.CLOSED; this.onclose?.(new CloseEvent('close')); } pushMessage(payload: unknown) { this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent); } } function installMockWebSocket() { MockWebSocket.instances = []; (globalThis as any).WebSocket = MockWebSocket; } function installMockFetch() { global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, data: { taskId: 'task-001' } }), }); } vi.mock('@/stores/device-store', () => ({ useDeviceStore: Object.assign( (selector: (s: any) => any) => selector({ fetchDevices: vi.fn() }), { getState: () => ({ fetchDevices: vi.fn() }) }, ), })); async function setupAtUpgradingPhase(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(); }); await waitFor(() => { expect(useFirmwareStore.getState().phase).toBe('upgrading'); }); return { ws: MockWebSocket.instances[MockWebSocket.instances.length - 1], onOpenChange, }; } // ────────────────────────────────────────────────────────────────────── // 8 種 reason × UI 驗證 // ────────────────────────────────────────────────────────────────────── interface ReasonCase { reason: FirmwareReason; stage: FirmwareProgressEvent['stage']; isDestructive: boolean; expectedButtonPattern: RegExp; description: string; } const REASON_CASES: ReasonCase[] = [ { reason: 'scan_not_found', stage: 'preparing', isDestructive: false, expectedButtonPattern: /Unplug and retry|插拔/, description: 'scan_not_found → ReplugRetry button', }, { reason: 'connect_failed', stage: 'preparing', isDestructive: false, expectedButtonPattern: /Retry|重試/, description: 'connect_failed → Retry button', }, { reason: 'loader_write_failed', stage: 'loading', isDestructive: false, expectedButtonPattern: /Retry|重試/, description: 'loader_write_failed → Retry button', }, { reason: 'upgrade_mid_failed', stage: 'flashing', isDestructive: false, expectedButtonPattern: /Retry|重試/, description: 'upgrade_mid_failed → Retry button', }, { reason: 'timeout', stage: 'flashing', isDestructive: false, expectedButtonPattern: /Unplug and rescan|拔插.*掃描/, description: 'timeout → Rescan button', }, { reason: 'disconnect_during_op', stage: 'flashing', isDestructive: true, expectedButtonPattern: /Contact|Support|聯絡|技術支援/, description: 'disconnect_during_op → Contact Support(destructive)', }, { reason: 'verify_mismatch', stage: 'verifying', isDestructive: true, expectedButtonPattern: /Contact|Support|聯絡|技術支援/, description: 'verify_mismatch → Contact Support(destructive)', }, { reason: 'verify_not_found', stage: 'verifying', isDestructive: true, expectedButtonPattern: /Contact|Support|聯絡|技術支援/, description: 'verify_not_found → Contact Support(destructive)', }, ]; describe('M9-5 E2E #2: 8 種失敗 reason 對應 friendly message + 復原 UI', () => { beforeEach(() => { installMockWebSocket(); installMockFetch(); useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null }); vi.spyOn(window, 'open').mockImplementation(() => null); }); it.each(REASON_CASES)( '$description', async ({ reason, stage, isDestructive, expectedButtonPattern }) => { const { ws } = await setupAtUpgradingPhase(); // 推 error event act(() => { ws.pushMessage({ type: 'firmware_progress', deviceId: 'kl520-0', stage: 'error', percent: -1, elapsedMs: 5000, reason, error: `simulated ${reason} failure`, rawError: `bridge.py raised ${reason}`, errorCode: `FW_${reason.toUpperCase()}_E001`, }); }); await waitFor(() => { expect(useFirmwareStore.getState().phase).toBe('error'); }); // 1. error modal 開啟 expect(screen.getByRole('alertdialog')).toBeTruthy(); // 2. errorCode 顯示 const errorCode = `FW_${reason.toUpperCase()}_E001`; expect(screen.getAllByText(new RegExp(errorCode)).length).toBeGreaterThanOrEqual(1); // 3. destructive vs recoverable if (isDestructive) { // brick warning role=note 存在 expect(screen.getByRole('note')).toBeTruthy(); // 沒有 retry 按鈕 const buttons = screen.getAllByRole('button'); const hasRetry = buttons.some((b) => /Retry|重試/.test(b.textContent || '')); expect(hasRetry).toBe(false); } else { // 沒有 brick warning expect(screen.queryByRole('note')).toBeNull(); } // 4. 主要按鈕對應 pattern const primaryBtn = screen .getAllByRole('button') .find((b) => expectedButtonPattern.test(b.textContent || '')); expect(primaryBtn).toBeTruthy(); }, ); it('Contact Support 按鈕點擊開 mailto: 並帶上技術資訊', async () => { const { ws } = await setupAtUpgradingPhase(); act(() => { ws.pushMessage({ type: 'firmware_progress', deviceId: 'kl520-0', stage: 'error', percent: -1, elapsedMs: 8000, reason: 'verify_mismatch', rawError: 'kp_update_kdp_firmware: version mismatch', errorCode: 'FW_VERIFY_MISMATCH_E201', }); }); await waitFor(() => { expect(useFirmwareStore.getState().phase).toBe('error'); }); const contactBtn = screen .getAllByRole('button') .find((b) => /Contact|Support|聯絡|技術支援/.test(b.textContent || '')); expect(contactBtn).toBeTruthy(); fireEvent.click(contactBtn!); expect(window.open).toHaveBeenCalled(); const [href] = (window.open as ReturnType).mock.calls[0]; expect(href).toMatch(/^mailto:/); // 應帶 errorCode 與 reason const decoded = decodeURIComponent(href); expect(decoded).toContain('FW_VERIFY_MISMATCH_E201'); expect(decoded).toContain('reason: verify_mismatch'); }); it('Retry 按鈕點擊後 store 回 confirming phase(準備重新升級)', async () => { const { ws } = await setupAtUpgradingPhase(); act(() => { ws.pushMessage({ type: 'firmware_progress', deviceId: 'kl520-0', stage: 'error', percent: -1, elapsedMs: 3000, reason: 'connect_failed', error: 'cannot connect to device', }); }); await waitFor(() => { expect(useFirmwareStore.getState().phase).toBe('error'); }); const retryBtn = screen .getAllByRole('button') .find((b) => /Retry|重試/.test(b.textContent || '')); expect(retryBtn).toBeTruthy(); // 點 retry → dialog handleRetry → setState confirming → handleStart 重連 WS await act(async () => { fireEvent.click(retryBtn!); await Promise.resolve(); await Promise.resolve(); }); // 走 handleStart 後 phase 又回 upgrading // (retry 成功 → store 進 upgrading;失敗 → 進 error) const phaseAfterRetry = useFirmwareStore.getState().phase; expect(['confirming', 'upgrading']).toContain(phaseAfterRetry); }); it('複製錯誤訊息按鈕 → navigator.clipboard.writeText 被叫', async () => { // jsdom 預設沒 clipboard、注入 mock const writeTextMock = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: writeTextMock }, }); const { ws } = await setupAtUpgradingPhase(); act(() => { ws.pushMessage({ type: 'firmware_progress', deviceId: 'kl520-0', stage: 'error', percent: -1, elapsedMs: 5000, reason: 'upgrade_mid_failed', rawError: 'flash write failed at offset 0x1000', errorCode: 'FW_UPGRADE_MID_E102', }); }); await waitFor(() => { expect(useFirmwareStore.getState().phase).toBe('error'); }); const copyBtn = screen.getByRole('button', { name: /Copy|複製/ }); fireEvent.click(copyBtn); expect(writeTextMock).toHaveBeenCalled(); const text = writeTextMock.mock.calls[0][0]; expect(text).toContain('stage: error'); expect(text).toContain('reason: upgrade_mid_failed'); expect(text).toContain('errorCode: FW_UPGRADE_MID_E102'); expect(text).toContain('rawError: flash write failed at offset 0x1000'); }); });