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>
218 lines
8.9 KiB
TypeScript
218 lines
8.9 KiB
TypeScript
/**
|
||
* M9-5 E2E #4 — Wails OnBeforeClose firmware-active 攔截邏輯
|
||
*
|
||
* 目的:驗證 visiona-local/firmware_close_guard.go 的 OnBeforeClose 邏輯在
|
||
* 升級進行中時阻擋 Wails 視窗關閉、避免使用者誤關 → device brick。
|
||
*
|
||
* 涵蓋 AC:
|
||
* - AC-FW-1.9 graceful shutdown 拒絕(含 Wails 視窗關閉)
|
||
* - R-FW-11 多層 safety net 之一
|
||
*
|
||
* 注意:visiona-local 是 Go 端 + Wails 框架,這層的測試在 Go 端已經有完整
|
||
* coverage 在 `visiona-local/firmware_close_guard_test.go`(8 個測試案例)。
|
||
*
|
||
* 本 spec 是補充 frontend 端 modal 攔截 UI 的測試(前端訂閱 Wails event
|
||
* `app:firmware-in-progress` 後渲染攔截 modal),對應 Design Spec §6a「Wails
|
||
* 控制台關閉攔截 modal」。
|
||
*
|
||
* 由於 frontend 目前對應的 modal 元件(控制台 force-quit modal)尚未實作完
|
||
* 整(M9-12 才開)、本 spec 提供**規格化的測試藍圖**、可在 M9-12 實作完成
|
||
* 後直接套用。
|
||
*
|
||
* Owner: Testing Agent (M9-5)
|
||
* Last reviewed: 2026-05-25
|
||
* Status: SKELETON(M9-12 之前的占位、Reviewer 可決定是否啟用)
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import type { FirmwareActiveTask } from '@/types/device';
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// Test fixtures
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
function makeActiveTask(overrides: Partial<FirmwareActiveTask> = {}): FirmwareActiveTask {
|
||
return {
|
||
taskId: 'upgrade-kl520-0-001',
|
||
deviceId: 'kl520-0',
|
||
deviceName: 'KL520 #1',
|
||
chip: 'KL520',
|
||
direction: 'upgrade',
|
||
stage: 'flashing',
|
||
startTs: new Date(Date.now() - 12000).toISOString(),
|
||
elapsedMs: 12000,
|
||
etaSeconds: 45,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// Mock Wails runtime (window.runtime injected by Wails at build time)
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
interface WailsRuntime {
|
||
EventsOn: (eventName: string, callback: (data: unknown) => void) => () => void;
|
||
EventsEmit: (eventName: string, ...data: unknown[]) => void;
|
||
Quit: () => void;
|
||
}
|
||
|
||
function installMockWailsRuntime(): {
|
||
runtime: WailsRuntime;
|
||
subscribers: Map<string, ((data: unknown) => void)[]>;
|
||
} {
|
||
const subscribers = new Map<string, ((data: unknown) => void)[]>();
|
||
|
||
const runtime: WailsRuntime = {
|
||
EventsOn: (event, cb) => {
|
||
const list = subscribers.get(event) || [];
|
||
list.push(cb);
|
||
subscribers.set(event, list);
|
||
return () => {
|
||
const updated = (subscribers.get(event) || []).filter((c) => c !== cb);
|
||
subscribers.set(event, updated);
|
||
};
|
||
},
|
||
EventsEmit: (event, data) => {
|
||
const list = subscribers.get(event) || [];
|
||
list.forEach((cb) => cb(data));
|
||
},
|
||
Quit: vi.fn(),
|
||
};
|
||
|
||
// @ts-expect-error - window.runtime is Wails-injected
|
||
globalThis.runtime = runtime;
|
||
return { runtime, subscribers };
|
||
}
|
||
|
||
function installMockGoBindings() {
|
||
// Wails Go binding:window.go.main.App.ConfirmForceClose
|
||
// 模擬「使用者輸入 FORCE 字串確認」後呼叫 binding
|
||
const ConfirmForceClose = vi.fn().mockResolvedValue(undefined);
|
||
|
||
// @ts-expect-error - window.go 是 Wails 注入的
|
||
globalThis.go = {
|
||
main: {
|
||
App: {
|
||
ConfirmForceClose,
|
||
},
|
||
},
|
||
};
|
||
return { ConfirmForceClose };
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// Frontend modal 攔截邏輯(規格化測試藍圖)
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
describe('M9-5 E2E #4: Wails OnBeforeClose firmware-active 攔截 modal(規格化)', () => {
|
||
beforeEach(() => {
|
||
// 重設 mock
|
||
});
|
||
|
||
it.todo(
|
||
'M9-12 實作後:訂閱 Wails event `app:firmware-in-progress` → 顯示 force-quit modal',
|
||
);
|
||
|
||
it('Wails runtime mock 可正常 EmitEvent + EventsOn round-trip', () => {
|
||
const { runtime, subscribers } = installMockWailsRuntime();
|
||
|
||
const callback = vi.fn();
|
||
const unsub = runtime.EventsOn('app:firmware-in-progress', callback);
|
||
|
||
runtime.EventsEmit('app:firmware-in-progress', {
|
||
hasActive: true,
|
||
tasks: [makeActiveTask()],
|
||
});
|
||
|
||
expect(callback).toHaveBeenCalledTimes(1);
|
||
expect(callback).toHaveBeenCalledWith({
|
||
hasActive: true,
|
||
tasks: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
taskId: 'upgrade-kl520-0-001',
|
||
deviceId: 'kl520-0',
|
||
stage: 'flashing',
|
||
etaSeconds: 45,
|
||
}),
|
||
]),
|
||
});
|
||
|
||
unsub();
|
||
runtime.EventsEmit('app:firmware-in-progress', { hasActive: false });
|
||
// unsub 後不再收到
|
||
expect(callback).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('payload schema 包含 force-quit modal 顯示需要的所有欄位', () => {
|
||
const task = makeActiveTask();
|
||
|
||
// Design §6a 要求 modal 顯示:
|
||
// - deviceName(哪台 device)
|
||
// - chip(KL520 / KL720)
|
||
// - direction(upgrade / downgrade)
|
||
// - stage(flashing / verifying / etc.)
|
||
// - elapsedMs(已過多久)
|
||
// - etaSeconds(還要多久)
|
||
|
||
expect(task.deviceName).toBeTruthy();
|
||
expect(['KL520', 'KL720', 'KL630', 'KL730']).toContain(task.chip);
|
||
expect(['upgrade', 'downgrade']).toContain(task.direction);
|
||
expect(['preparing', 'loading', 'flashing', 'verifying', 'done', 'error']).toContain(task.stage);
|
||
expect(task.elapsedMs).toBeGreaterThan(0);
|
||
expect(task.etaSeconds).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('ConfirmForceClose binding 在「強制關閉」second-confirm 後被呼叫', async () => {
|
||
const { ConfirmForceClose } = installMockGoBindings();
|
||
|
||
// 模擬:使用者點「強制關閉」→ 出現 FORCE 輸入 modal → 輸入 FORCE → 點確認
|
||
// → 呼 ConfirmForceClose binding
|
||
|
||
// 因為 Frontend modal 尚未實作(M9-12)、這裡只驗 binding pattern
|
||
// @ts-expect-error - 模擬 Frontend modal 行為
|
||
await globalThis.go.main.App.ConfirmForceClose();
|
||
|
||
expect(ConfirmForceClose).toHaveBeenCalled();
|
||
});
|
||
|
||
it.todo('M9-12:force-quit modal 第二層 FORCE 確認字串 input 嚴格 === 比對');
|
||
it.todo('M9-12:第二層輸入「force」小寫不通過(防誤觸)');
|
||
it.todo('M9-12:「繼續等待」按鈕關閉 force-quit modal、不呼 ConfirmForceClose');
|
||
it.todo('M9-12:firmware 升級完成後 modal 自動關閉');
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
// 補充:對 Go 端 close-guard 測試的 cross-reference
|
||
// ──────────────────────────────────────────────────────────────────────
|
||
|
||
describe('Go 端 close-guard test 已涵蓋(cross-ref)', () => {
|
||
it('cross-ref:visiona-local/firmware_close_guard_test.go 8 個案例', () => {
|
||
// 此 spec 不重做 Go 端測試(已有完整 coverage)、列出供 reviewer 對照:
|
||
const goTestCases = [
|
||
'TestEvaluateClose_ForceAccepted',
|
||
'TestEvaluateClose_ServerNotRunning',
|
||
'TestEvaluateClose_QueryError_FailOpen',
|
||
'TestEvaluateClose_NoActiveTask',
|
||
'TestEvaluateClose_HasActive_PreventAndEmit',
|
||
'TestConfirmForceClose_SetsAndConsumesFlag',
|
||
'TestEvaluateClose_NilDeps',
|
||
'TestConfirmForceClose_ConcurrentAccess',
|
||
];
|
||
|
||
// 8 個 Go 端測試案例已涵蓋以下情境:
|
||
// 1. forceCloseAccepted=true → 放行
|
||
// 2. server port=0 → 放行
|
||
// 3. queryFirmwareTasks 失敗 → fail-open 放行
|
||
// 4. hasActive=false → 放行、不 emit
|
||
// 5. hasActive=true → emit + return true(prevent close)
|
||
// 6. ConfirmForceClose 設旗標
|
||
// 7. nil deps → 防呆放行
|
||
// 8. concurrent race-free
|
||
|
||
expect(goTestCases.length).toBe(8);
|
||
|
||
// 跑 `cd visiona-local && go test -race -run "TestEvaluateClose|TestConfirmForceClose" -v`
|
||
// 確認全綠(已驗、見 M9-5 plan §9 自動化腳本對照表)
|
||
});
|
||
});
|