visionA/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-error-recovery.spec.ts
jim800121chen 8c27da7cca test(local-tool): M9-5 — three-platform validation plan + e2e scripts + MJ3 fix
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>
2026-05-25 15:34:17 +08:00

357 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 Supportdestructive',
},
{
reason: 'verify_mismatch',
stage: 'verifying',
isDestructive: true,
expectedButtonPattern: /Contact|Support|聯絡|技術支援/,
description: 'verify_mismatch → Contact Supportdestructive',
},
{
reason: 'verify_not_found',
stage: 'verifying',
isDestructive: true,
expectedButtonPattern: /Contact|Support|聯絡|技術支援/,
description: 'verify_not_found → Contact Supportdestructive',
},
];
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');
});
});