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>
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
/**
|
||
* 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> = {}): 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');
|
||
});
|
||
});
|