visionA/local-tool/.autoflow/06-testing/scripts/firmware-r-fw-11-modal-not-closable.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

304 lines
10 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 #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 OnBeforeClosewails-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 鍵不關 modalonOpenChange(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 即可
});
});