A 階段最後 milestone、出測試計畫 + 自動化腳本 + 三平台人工 checklist、使用者下週手動跑實機驗證。 Testing artifacts (8 檔、2630 行): - .autoflow/06-testing/m9-5-validation-plan.md: 656 行(4 情境 × 3 平台 × 2 chip = 24 combo) - 4 e2e specs (vitest + RTL + mock WS / mock fetch): - firmware-upgrade-happy-path.spec.ts (357 / 4 cases) - firmware-upgrade-error-recovery.spec.ts (356 / 4 cases + 8 reason it.each) - firmware-r-fw-11-modal-not-closable.spec.ts (303 / 6 cases) - wails-onbeforeclose-firmware-active.spec.ts (217 / 9 cases、含 5 todo 占位 M9-12) - 3 manual checklists: macOS 264 / Windows 234 / Linux 243 行 設計取捨: - 不引入 Playwright/Cypress (visionA-local frontend 沒裝、屬 architect 決策)、走 vitest + mock - E2E 腳本放 06-testing/scripts/ 作 spec doc + 可選實作參考 - 實機驗證走人工 checklist (dongle 插拔 / kill process / SIGTERM 等需要實體互動) MJ3 修復 (M9-4 reviewer round 1 留的 follow-up): - server/internal/api/ws/firmware_ws_test.go: +16/-8 - "type": "firmware:progress" → "firmware_progress" (對齊 firmwareProgressMessage.Type) - "phase" → "stage" (對齊 TDD §4.2 + FirmwareProgress.Stage) - 不動 production code、只 test schema 對齊 執行建議 (給你下週): - Day 1 P0: macOS+Win+Linux × KL520+KL720 happy path (~3h) - Day 2 P1: R-FW-11 + disconnect_during_op + upgrade_mid_failed + 失敗注入 (4h) - Day 3 P2: SIGTERM 延遲關閉 + Wails OnBeforeClose force-quit modal (2-3h) 測試: - go test ./... -race 全綠 (server / wails / frontend 60 tests) - MJ3 修復不破壞既有測試 A 階段開發 6/7 完成 (M9 文件 + M9-1 ~ M9-4.5)、剩 M9-5 實機驗證 (你下週跑)、跑完依結果決定 A 階段交付或派 sub-agent 修。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
/**
|
||
* 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> = {}): 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(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
|
||
|
||
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<typeof vi.fn>).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');
|
||
});
|
||
});
|