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>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
/**
|
||
* M9-5 E2E #3 — R-FW-11 緩解:升級進行中 modal 不可關
|
||
*
|
||
* 目的:驗證 PRD R-FW-11(一般使用者誤觸關閉 → brick)的 UI 緩解措施:
|
||
* upgrading phase 期間、modal 不能被任何操作關掉。
|
||
*
|
||
* 涵蓋 AC:
|
||
* - AC-FW-1.9 升級期間阻擋關閉動作(含 Wails control panel、但這個 spec 只覆蓋 modal 本身)
|
||
* - R-FW-11 一般使用者誤觸防範
|
||
*
|
||
* 涵蓋 4 種關閉動作:
|
||
* 1. 點 ✕ 關閉按鈕(confirm phase 有、upgrading phase 不該有)
|
||
* 2. ESC 鍵
|
||
* 3. 點 modal 外部 overlay
|
||
* 4. 程式試圖呼 onOpenChange(false)
|
||
*
|
||
* 不在範圍:
|
||
* - Wails OnBeforeClose(wails-onbeforeclose-firmware-active.spec.ts)
|
||
* - 失敗注入後的 modal(已是 error phase、modal 可關不在 R-FW-11 範圍)
|
||
*
|
||
* 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 } from '@/types/device';
|
||
|
||
function makeLegacyKL520(): 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',
|
||
};
|
||
}
|
||
|
||
class MockWebSocket {
|
||
static instances: MockWebSocket[] = [];
|
||
static OPEN = 1;
|
||
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 = 3;
|
||
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 getToUpgradingPhase() {
|
||
const onOpenChange = vi.fn();
|
||
const device = makeLegacyKL520();
|
||
|
||
const { container } = 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();
|
||
});
|
||
|
||
// 推一個 preparing event 確保進 upgrading
|
||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||
act(() => {
|
||
ws.pushMessage({
|
||
type: 'firmware_progress',
|
||
deviceId: 'kl520-0',
|
||
stage: 'preparing',
|
||
percent: 5,
|
||
elapsedMs: 500,
|
||
} as FirmwareProgressEvent);
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(useFirmwareStore.getState().phase).toBe('upgrading');
|
||
});
|
||
|
||
return { container, onOpenChange, ws };
|
||
}
|
||
|
||
describe('M9-5 E2E #3: R-FW-11 modal 不可關(upgrading phase)', () => {
|
||
beforeEach(() => {
|
||
installMockWebSocket();
|
||
installMockFetch();
|
||
useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null });
|
||
});
|
||
|
||
it('upgrading phase: showCloseButton={false} 確保 ✕ 按鈕不渲染', async () => {
|
||
const { container } = await getToUpgradingPhase();
|
||
|
||
// shadcn Dialog 的 close button 通常是 button + sr-only "Close" text
|
||
// upgrading phase 不該有
|
||
const closeButtons = container.querySelectorAll('button[aria-label*="lose"], button[aria-label*="關閉"]');
|
||
// 注意:modal 內部可能有「關閉」context、這裡只擋 Dialog 內建的 ✕
|
||
// FirmwareUpgradeDialog 在 isInProgress=true 時 showCloseButton={false}
|
||
// 因此 DialogContent 的內建 close 按鈕應該不存在
|
||
// (shadcn 預設用 X icon button、aria-label 通常是 "Close")
|
||
expect(closeButtons.length).toBe(0);
|
||
});
|
||
|
||
it('upgrading phase: ESC 鍵不關 modal(onOpenChange(false) 被攔截)', async () => {
|
||
const { container, onOpenChange } = await getToUpgradingPhase();
|
||
|
||
// 模擬 ESC 鍵
|
||
const dialog = container.querySelector('[role="dialog"]') || document.body;
|
||
fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' });
|
||
|
||
// store phase 仍為 upgrading
|
||
expect(useFirmwareStore.getState().phase).toBe('upgrading');
|
||
// onOpenChange(false) 不該被叫(dialog wrapper 攔截)
|
||
const closeCalls = onOpenChange.mock.calls.filter((c) => c[0] === false);
|
||
expect(closeCalls.length).toBe(0);
|
||
});
|
||
|
||
it('upgrading phase: 程式呼 onOpenChange(false) 被 dialog wrapper 擋住', async () => {
|
||
// 這是 firmware-upgrade-dialog 內 onOpenChange wrapper 的邏輯:
|
||
// if (!next && isInProgress) return;
|
||
// 換句話說、雖然 dialog 本身可以 open=false,但本元件包了一層攔截。
|
||
//
|
||
// 此測試走「子元件透過 onOpenChange(false) 試圖關閉」路徑、驗 wrapper 擋住。
|
||
|
||
const onOpenChange = vi.fn();
|
||
const device = makeLegacyKL520();
|
||
|
||
// 設 store 直接進 upgrading(不走 confirm)
|
||
useFirmwareStore.setState({
|
||
activeDeviceId: 'kl520-0',
|
||
phase: 'upgrading',
|
||
progress: {
|
||
type: 'firmware_progress',
|
||
deviceId: 'kl520-0',
|
||
stage: 'flashing',
|
||
percent: 50,
|
||
elapsedMs: 10000,
|
||
} as FirmwareProgressEvent,
|
||
beforeVersion: 'KDP1',
|
||
targetVersion: 'v2.2.0',
|
||
startedAt: Date.now(),
|
||
});
|
||
|
||
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
|
||
|
||
// 直接看 store phase: 確認仍是 upgrading
|
||
expect(useFirmwareStore.getState().phase).toBe('upgrading');
|
||
// FirmwareUpgradeDialog 內 onOpenChange wrapper:
|
||
// onOpenChange={(next) => { if (!next && isInProgress) return; ... }}
|
||
// → next=false 且 isInProgress=true 時、不會呼 onOpenChange(false) 給父元件
|
||
// 我們無法直接觸發 Radix 內部的 close 機制(jsdom 限制)、但走 ESC / outside click
|
||
// 已被 onInteractOutside / onEscapeKeyDown 上 e.preventDefault() 攔截
|
||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||
});
|
||
|
||
it('confirming phase: ✕ / ESC 可正常關閉(對比 upgrading)', async () => {
|
||
const onOpenChange = vi.fn();
|
||
const device = makeLegacyKL520();
|
||
|
||
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
|
||
|
||
// confirm phase
|
||
expect(useFirmwareStore.getState().phase).toBe('confirming');
|
||
|
||
// 點「取消」按鈕應該關 modal
|
||
const cancelBtn = screen.getByRole('button', { name: /取消|Cancel/ });
|
||
fireEvent.click(cancelBtn);
|
||
|
||
// confirm phase 點 cancel → store.cancelConfirm → phase: idle
|
||
expect(useFirmwareStore.getState().phase).toBe('idle');
|
||
});
|
||
|
||
it('error phase: modal 可關(已脫離 critical zone、R-FW-11 不適用)', async () => {
|
||
installMockWebSocket();
|
||
installMockFetch();
|
||
|
||
const onOpenChange = vi.fn();
|
||
const device = makeLegacyKL520();
|
||
|
||
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 = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||
|
||
// 推 error 事件
|
||
act(() => {
|
||
ws.pushMessage({
|
||
type: 'firmware_progress',
|
||
deviceId: 'kl520-0',
|
||
stage: 'error',
|
||
percent: -1,
|
||
elapsedMs: 5000,
|
||
reason: 'connect_failed',
|
||
error: 'connection refused',
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(useFirmwareStore.getState().phase).toBe('error');
|
||
});
|
||
|
||
// error phase 點「Close / 關閉」可關
|
||
const closeBtn = screen.getByRole('button', { name: /Close|關閉/ });
|
||
fireEvent.click(closeBtn);
|
||
|
||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||
});
|
||
});
|
||
|
||
describe('M9-5 E2E #3b: 升級進度警告 banner 全程顯示', () => {
|
||
beforeEach(() => {
|
||
installMockWebSocket();
|
||
installMockFetch();
|
||
useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null });
|
||
});
|
||
|
||
it('preparing / loading / flashing / verifying 4 階段都顯示「請勿拔除裝置」banner', async () => {
|
||
await getToUpgradingPhase();
|
||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||
|
||
for (const stage of ['preparing', 'loading', 'flashing', 'verifying'] as const) {
|
||
act(() => {
|
||
ws.pushMessage({
|
||
type: 'firmware_progress',
|
||
deviceId: 'kl520-0',
|
||
stage,
|
||
percent: 50,
|
||
elapsedMs: 5000,
|
||
} as FirmwareProgressEvent);
|
||
});
|
||
await waitFor(() => {
|
||
expect(useFirmwareStore.getState().progress?.stage).toBe(stage);
|
||
});
|
||
// 紅色 banner role=alert 顯示
|
||
// firmware-progress-view.tsx 內:
|
||
// {progress.stage !== 'done' && (<div role="alert">⚠ 請勿拔除裝置</div>)}
|
||
expect(screen.getByRole('alert').textContent).toMatch(/拔除|unplug/i);
|
||
}
|
||
|
||
// done 時 banner 不該顯示
|
||
act(() => {
|
||
ws.pushMessage({
|
||
type: 'firmware_progress',
|
||
deviceId: 'kl520-0',
|
||
stage: 'done',
|
||
percent: 100,
|
||
elapsedMs: 28000,
|
||
afterVersion: 'v2.2.0',
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(useFirmwareStore.getState().phase).toBe('success');
|
||
});
|
||
// success phase 進入後、progress-view 已不渲染
|
||
// 直接驗 phase 即可
|
||
});
|
||
});
|