visionA/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-happy-path.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

358 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 #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 handlepreparing/loading/flashing/verifying/done
* - 4-stage 路徑isLegacyUpgrade=truevs 3-stagefalse對比
*
* 不在範圍(其他 spec 覆蓋):
* - R-FW-11 modal 不可關firmware-r-fw-11-modal-not-closable.spec.ts
* - 失敗注入firmware-upgrade-error-recovery.spec.ts
* - Wails OnBeforeClosewails-onbeforeclose-firmware-active.spec.ts
*
* 執行方式:
* - 此檔放 .autoflow/06-testing/scripts/ 是 spec doc 兼參考實作
* - 真要跑:複製到 frontend/src/tests/e2e/、用 pnpm test 跑 vitest
* - 或:使用者下週實機驗證時對照本 spec 的 step 跑人工 checklist
*
* 設計取捨:
* - 不用 Playwright/CypressvisionA-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> = {}): 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> = {}): 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> = {},
): 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(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
// 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(<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();
});
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(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={() => {}} />);
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(<FirmwareUpgradeDialog device={deviceA} open={true} onOpenChange={() => {}} />);
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');
});
});