From 8c27da7ccabcfbb1af738f794bd504879db4f92b Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Mon, 25 May 2026 15:34:17 +0800 Subject: [PATCH] =?UTF-8?q?test(local-tool):=20M9-5=20=E2=80=94=20three-pl?= =?UTF-8?q?atform=20validation=20plan=20+=20e2e=20scripts=20+=20MJ3=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../06-testing/m9-5-validation-plan.md | 656 ++++++++++++++++++ ...irmware-r-fw-11-modal-not-closable.spec.ts | 303 ++++++++ .../firmware-upgrade-error-recovery.spec.ts | 356 ++++++++++ .../firmware-upgrade-happy-path.spec.ts | 357 ++++++++++ .../scripts/manual-checklist-linux.md | 243 +++++++ .../scripts/manual-checklist-macos.md | 264 +++++++ .../scripts/manual-checklist-windows.md | 234 +++++++ ...ails-onbeforeclose-firmware-active.spec.ts | 217 ++++++ local-tool/.autoflow/progress.md | 14 +- .../internal/api/ws/firmware_ws_test.go | 24 +- 10 files changed, 2659 insertions(+), 9 deletions(-) create mode 100644 local-tool/.autoflow/06-testing/m9-5-validation-plan.md create mode 100644 local-tool/.autoflow/06-testing/scripts/firmware-r-fw-11-modal-not-closable.spec.ts create mode 100644 local-tool/.autoflow/06-testing/scripts/firmware-upgrade-error-recovery.spec.ts create mode 100644 local-tool/.autoflow/06-testing/scripts/firmware-upgrade-happy-path.spec.ts create mode 100644 local-tool/.autoflow/06-testing/scripts/manual-checklist-linux.md create mode 100644 local-tool/.autoflow/06-testing/scripts/manual-checklist-macos.md create mode 100644 local-tool/.autoflow/06-testing/scripts/manual-checklist-windows.md create mode 100644 local-tool/.autoflow/06-testing/scripts/wails-onbeforeclose-firmware-active.spec.ts diff --git a/local-tool/.autoflow/06-testing/m9-5-validation-plan.md b/local-tool/.autoflow/06-testing/m9-5-validation-plan.md new file mode 100644 index 0000000..583435c --- /dev/null +++ b/local-tool/.autoflow/06-testing/m9-5-validation-plan.md @@ -0,0 +1,656 @@ +# M9-5 三平台實機驗證計畫 — Kneron Dongle FW 升降版 + +> 對應 PRD:`.autoflow/02-prd/features/feature-firmware-management.md`(AC-FW-1.1 ~ AC-FW-1.9) +> 對應 TDD:`.autoflow/04-architecture/v2/firmware-management.md` §11(測試策略) +> 對應 Design:`.autoflow/03-design/v2/firmware-management.md` §7(失敗復原 UX 8 種) +> 階段:**A 階段最後 milestone**(M9-5、1 人天、Testing 出 plan + 自動化腳本、使用者下週實機驗證) +> 版本:v1.0(2026-05-25) +> 作者:Testing Agent + +--- + +## 0. 文件結構導覽 + +| 章節 | 主題 | +|------|------| +| §1 | 範圍與驗證目標 | +| §2 | 測試環境前置(硬體 + 軟體) | +| §3 | 三平台 × 兩 chip × 四情境 × 一致驗證表(24 combo) | +| §4 | 功能性驗證(Functional) — happy path、4 情境 | +| §5 | 可靠性驗證(Reliability) — 失敗注入 8 種 reason | +| §6 | 效能驗證(Performance) — 升級時長護欄 | +| §7 | UX 驗證 — badge / modal / 復原 UI / R-FW-11 modal 不可關 | +| §8 | 整合驗證 — SIGTERM / Wails OnBeforeClose / force-quit modal | +| §9 | 自動化腳本對照表(E2E unit-style + 手動 checklist) | +| §10 | 通過 / 失敗判定規則 | +| §11 | Bug 回報格式 | +| §12 | 下週執行建議順序(哪平台先 / 哪情境先) | +| §13 | Pass → next-step / Fail → 派工建議 | + +--- + +## 1. 範圍與驗證目標 + +### 1.1 範圍邊界 + +**在範圍**(M9-5 必驗): +- KL520 + KL720 兩 chip 的 FW 升級流程(A 階段、KDP1 → KDP2 + KDP2 → KDP2 short-circuit) +- macOS / Windows / Linux 三平台 +- 4 個升級情境(§4.1) +- 8 種失敗注入路徑(§5.2) +- AC-FW-1.1 ~ AC-FW-1.9 全部 acceptance criteria +- §8.6 graceful shutdown + Wails OnBeforeClose force-quit modal + +**不在範圍**(B 階段或 post-launch): +- KL630 / KL730 升降版(B 階段 M9-10 才開、AC-FW-3.5) +- 手動降版(B 階段 M9-11/12) +- 多版本目錄結構(B 階段 M9-9) +- KneronPLUS wheel 升 3.1.2 跨主版本回歸(B 階段 M9-13) +- Beta usability test(UR-1~3、post-launch) + +### 1.2 驗證目標(pass criteria) + +| 類別 | 目標 | +|------|------| +| Functional | 4 情境 × 6 combo(3 平台 × 2 chip)= 24 個升級實驗、success rate ≥ 95% | +| Reliability | 8 種 reason 注入測試、UI friendly message 正確顯示、no server crash | +| Performance | KL520 ≤ 60s 上界、KL720 ≤ 200s 上界(AC-FW-1.7 + TDD §7.2 護欄) | +| UX | Badge 4 色顯示正確、modal 不可關(R-FW-11)、復原 UI 對應 8 種 reason | +| Integration | SIGTERM 延遲關閉、Wails OnBeforeClose force-quit modal 阻擋成功 | + +--- + +## 2. 測試環境前置 + +### 2.1 硬體(使用者已備齊) + +| 項目 | 說明 | +|------|------| +| KL520 dongle | USB Boot 版(KDP1 legacy)+ KDP2 standard 版各 1 根;若只有 KDP2 dongle、可從 KDP2 跑 short-circuit 路徑 | +| KL720 dongle | 至少 1 根(已預燒 KDP2、會走 short-circuit) | +| macOS 機器 | Apple Silicon 或 Intel x86_64(兩者皆可) | +| Windows 機器 | Windows 10/11、x86_64、有 admin 權限(KneronPLUS WinUSB driver) | +| Linux 機器 | Ubuntu 20.04+ 或同等 distro、udev rules 已綁定 | +| USB hub | 推薦(拔插測試方便、不必碰主機 port) | + +### 2.2 軟體 + +每平台需要: +- visionA-local 最新 A 階段 build(含 M9-1 ~ M9-4 commits) +- Chrome / Edge / Firefox 任一(測 UI、瀏覽器 tab) +- Terminal / Console(看 server log、跑 `git log` 等) + +### 2.3 測試資料 + +- 純淨環境:每平台跑前刪 `~/Library/Application Support/visiona-local/` 或同等位置、確保沒有 leftover state +- KL520 KDP1 dongle 是必要的(最重要的 happy path);若沒有、降級為「KDP2 short-circuit only」+ §11 bug-report 指出此 gap + +--- + +## 3. 三平台 × 兩 chip × 四情境驗證表 + +每個 cell = 1 個驗證實驗。理想全部 ✅,部分 ⚠ partial 可接受、❌ 阻擋發布。 + +| # | 平台 | Chip | 情境 | 預期 stage 序列 | timeout 上界 | 驗證標準 | +|---|------|------|------|---------------|------------|---------| +| 1 | macOS | KL520 | KDP1 → KDP2 完整升級(4 stage) | preparing → loading → flashing → verifying → done | 60s | §4.1 情境 A | +| 2 | macOS | KL520 | KDP2 → KDP2 short-circuit(3 stage) | preparing → flashing → verifying → done | 60s | §4.1 情境 B | +| 3 | macOS | KL720 | KDP2 升級 / load_firmware_from_file | preparing → flashing → verifying → done | 200s | §4.1 情境 C | +| 4 | Windows | KL520 | KDP1 → KDP2 完整升級 | 同 1 | 60s | §4.1 情境 A | +| 5 | Windows | KL520 | KDP2 short-circuit | 同 2 | 60s | §4.1 情境 B | +| 6 | Windows | KL720 | KDP2 升級 | 同 3 | 200s | §4.1 情境 C | +| 7 | Linux | KL520 | KDP1 → KDP2 完整升級 | 同 1 | 60s | §4.1 情境 A | +| 8 | Linux | KL520 | KDP2 short-circuit | 同 2 | 60s | §4.1 情境 B | +| 9 | Linux | KL720 | KDP2 升級 | 同 3 | 200s | §4.1 情境 C | +| 10 | macOS | KL520 | 來回升級(情境 A 完成後再跑 A) | 兩次 1 stage 序列 | 各 60s | §4.1 情境 D | +| 11 | Windows | KL520 | 來回升級 | 同 10 | 各 60s | §4.1 情境 D | +| 12 | Linux | KL520 | 來回升級 | 同 10 | 各 60s | §4.1 情境 D | + +**Functional combo = 12**(3 平台 × 2 chip × 2 主情境 = 12;情境 D 是延伸驗證、加 3 combo = 15、但 reliability + UX + integration 各加部分 combo、合計約 24 個實驗)。 + +**Reliability 額外實驗**: +- 13-20:每平台跑「拔 USB 中斷」「kill bridge.py」「kill server」「modal R-FW-11 不可關」4 種注入,至少 macOS + Windows 各 4 = 8 combo(Linux 補上 = 12) + +**Integration 額外實驗**: +- 21-24:每平台跑「升級期間關 Wails 視窗→force-quit modal」「升級期間 SIGTERM→延遲關閉」2 種、合計 6 combo + +合計 ~24 個 combo,使用者可依時間裁切(§12 給優先順序)。 + +--- + +## 4. 功能性驗證(Functional) + +### 4.1 4 個升級情境(每平台 × 每 chip 都跑) + +#### 情境 A:KDP1 → KDP2 完整升級(4 stage、AC-FW-1.2) + +**前置條件**:插一根 KL520 KDP1 legacy dongle(firmware 字串 `KDP Comp/U` 或類似)。 + +**測試步驟**: + +1. 確認 visionA-local 已啟動、瀏覽器開 Devices 頁 +2. 確認偵測到 dongle、卡片右上角 FW badge **紅色** + 文字「KDP1 (legacy)」+ tooltip「此韌體為舊版 KDP1...」 +3. 點卡片內「升級到最新」按鈕(labels:`升級到 v2.2.0`) +4. 確認 confirm modal 顯示: + - 標題「升級韌體」 + - from/to:from = `KDP1`、to = `v2.2.0` + - 警告:「升級過程約需 30 秒、期間切勿拔除裝置或關閉應用程式」 + - 「開始升級」按鈕(primary、不是 destructive) +5. 點「開始升級」、modal 切到 upgrading phase +6. 確認 progress modal: + - **不顯示** ✕ / 取消按鈕(R-FW-11 緩解) + - progress bar 從 0% 開始 + - 階段文字依序變化:「階段 1 / 4:準備(偵測 + 連接裝置)」→「階段 2 / 4:載入引導程式」→「階段 3 / 4:寫入韌體」→「階段 4 / 4:驗證完成」 + - 紅色 banner「請勿拔除裝置」全程顯示 + - 「已耗時」秒數遞增、「預估剩餘」秒數遞減(依 etaMs 計算) + - ESC / 點外部不關 modal(捕獲、抖動或無反應) +7. 升級成功(30 秒內)、modal 短暫顯示綠勾 ✓ 「完成」+ afterVersion `v2.2.0` +8. 約 1.5 秒後 modal 自動關閉 +9. 右上角 toast 出現:「KL520 #1 升級成功 — 從 KDP1 升級到 v2.2.0(耗時 X 秒)」、停留 6 秒 +10. Devices 卡片 FW badge **變綠**、firmware version `v2.2.0` + +**Pass 條件**: +- ✅ 全部步驟完成、所有 UI 元素如預期 +- ✅ 升級在 60s 內完成(KL520 timeout 上界) +- ✅ 4 stage 全部出現、順序正確 +- ✅ Server log 沒有 panic、error + +#### 情境 B:KDP2 → KDP2 short-circuit(3 stage) + +**前置條件**:KL520 dongle 已是 KDP2、firmware 字串為 `v2.1.0` 或 `v2.2.x` 但比 bundled current 舊(觸發 yellow badge)。或情境 A 跑完後重新跑情境 B。 + +**測試步驟**: + +1. Devices 頁卡片 FW badge **黃色**(older) +2. 點升級按鈕、confirm modal +3. 點「開始升級」、modal 切 upgrading +4. 確認 progress stage 序列:**3 stage**(不是 4):「階段 1 / 3:準備」→「階段 2 / 3:寫入韌體」→「階段 3 / 3:驗證完成」(**沒有** loading 階段) +5. 升級成功、badge 變綠 + +**Pass 條件**: +- ✅ 3 stage 而不是 4 stage(核心差異、`stageOrdinal` helper 邏輯驗證) +- ✅ 升級在 60s 內完成 + +#### 情境 C:KL720 升級 + +**前置條件**:KL720 dongle(firmware 為 KDP2,可能與 bundled current 一樣或舊)。 + +**測試步驟**: + +1. Devices 頁卡片 badge:綠 (若 already current) 或黃 (older 可升) +2. 若是黃色,點升級 +3. 同情境 A/B,但 estimated duration 標 `3 分鐘`(180 秒) +4. progress modal stage 序列同 KDP2 short-circuit + +**Pass 條件**: +- ✅ 升級在 200s 內完成(KL720 timeout 上界) +- ✅ progress 跑滿且沒有卡死 + +#### 情境 D:來回升級(regression) + +**前置條件**:情境 A 已跑完、再跑一次完整 KDP1 → KDP2 升級流程(如果有第二根 KL520 KDP1 legacy dongle,建議用第二根;否則只能跑一次)。 + +**測試步驟**: + +1. 重新跑情境 A +2. 確認第二次升級不會: + - 被「Error 15 SEND_DATA_TOO_LARGE」打斷(既有 KL520 reset bug fix 應該已處理 `needsReset=true`) + - bridge.py 帶舊 firmware state 連線 +3. 第二次升級流程跟第一次完全一樣 + +**Pass 條件**: +- ✅ 沒有 Error 15 +- ✅ 連續兩次升級都成功 + +### 4.2 多裝置同時升級(per-device 隔離、firmware-store activeDeviceId 機制) + +**前置條件**:兩根 dongle 同時連接(建議 1× KL520 + 1× KL720)。 + +**測試步驟**: + +1. Devices 頁顯示兩張卡片 +2. 點 KL520 卡片的升級按鈕(modal 開) +3. 升級進行中、**切回 Devices 頁** 看另一張 KL720 卡片狀態 +4. 點 KL720 卡片的升級按鈕 — 預期:**會被擋住**(因為 firmware-store 一次只允許一個 activeDeviceId、其他 device 的事件被忽略) + - 或者 modal 開但顯示「裝置正在進行其他作業」(409 FW_DEVICE_BUSY) +5. 等 KL520 完成 +6. 點 KL720 升級、流程正常 + +**Pass 條件**: +- ✅ 第一個升級進行中、不會被第二個 modal 蓋掉 +- ✅ Frontend store 不出現「兩個 progress event 互相覆蓋」 + +--- + +## 5. 可靠性驗證(Reliability、失敗注入) + +### 5.1 對應 Design §7.1 的 8 種失敗類型 + +| # | 失敗類型 | 注入方法 | 預期 reason | 預期 UI | +|---|---------|---------|------------|--------| +| 1 | scan_not_found | 升級前拔 USB → 立刻點升級 | `scan_not_found` | error modal「找不到裝置」+「重新插拔後重試」按鈕 | +| 2 | connect_failed | 多開 visionA-local(兩 instance 搶 device)→ 點升級 | `connect_failed` | 「無法連接裝置」+「重試」按鈕 | +| 3 | loader_write_failed | 升級到 loading stage 時 `pkill -9 python` | `loader_write_failed` | 「引導程式載入失敗」+「拔插後重試」 | +| 4 | upgrade_mid_failed | 升級到 flashing stage 時 `pkill -9 python` | `upgrade_mid_failed` | 「韌體寫入失敗、聯絡技術支援」+「複製錯誤訊息」+「取得協助」(mailto:) | +| 5 | disconnect_during_op | 升級到 flashing stage 時拔 USB | `disconnect_during_op` | brick warning banner + 「Contact Support」(**不顯示 retry 按鈕**) | +| 6 | timeout | 修改 bridge.py 加 `time.sleep(70)` 模擬 KL520 ≥ 60s(或 KL720 ≥ 200s) | `timeout` | 「操作超時」+「拔插後重新掃描」 | +| 7 | verify_mismatch | 升級成功但 backend 偽造 firmware 字串不對(需 mock 或實機難以重現) | `verify_mismatch` | brick warning + ContactSupport | +| 8 | verify_not_found | 升級成功但 rescan 找不到 device | `verify_not_found` | brick warning + ContactSupport | + +**實機限制**:#7 verify_mismatch / #8 verify_not_found 在實機很難穩定重現、建議用 E2E 自動化(mock backend)覆蓋。 + +### 5.2 注入測試步驟模板 + +對每個失敗類型: +1. 啟動 visionA-local +2. 跑情境 A 或 B 升級流程到 confirm modal +3. 點「開始升級」、進入 upgrading phase +4. 等到指定 stage、執行注入動作(拔 USB / kill process / etc.) +5. 觀察 UI 變化 +6. 截圖 + 紀錄 timestamp + +### 5.3 重點驗證項 + +對每個失敗: +- [ ] error modal 顯示對應 friendly message(中文 / 英文視 i18n 設定) +- [ ] 顯示 errorCode(如 `FW_E102`) +- [ ] 技術資訊區可展開、包含 stage / reason / deviceId / rawError +- [ ] 「複製錯誤訊息」按鈕可點、複製成功變「已複製 ✓」 +- [ ] 若是 destructive reason(disconnect/verify_mismatch/verify_not_found): + - [ ] 顯示 brick warning role=note + - [ ] 不顯示 Retry 按鈕 + - [ ] 顯示「Contact Support」destructive 按鈕(enabled、點擊開 mailto:) +- [ ] 若是 recoverable reason(其他): + - [ ] 顯示 Retry / ReplugRetry / Rescan 按鈕 + - [ ] 點 Retry 後流程重啟(store 回 confirming phase + 重新打 API) +- [ ] Server log 無 panic、無 crash +- [ ] Devices 頁卡片狀態不會卡住、re-plug 後可繼續用 + +--- + +## 6. 效能驗證(Performance) + +### 6.1 升級時長護欄(AC-FW-1.7、TDD §7.2) + +| Chip | 預估 | 護欄上界 | 護欄超過行為 | +|------|------|---------|-------------| +| KL520 | ~30s(warrenchen 實測) | 60s | timeout、UI 顯示 `Reason="timeout"` | +| KL720 | ~180s(warrenchen 實測) | 200s | timeout、UI 顯示 `Reason="timeout"` + `FW_UPGRADE_BRICK_RISK` | + +### 6.2 量測方法 + +對每個 functional 升級實驗(§3 表格 1-12): +1. 開瀏覽器 DevTools Network tab、觀察 WS 「firmware_progress」event 時序 +2. 紀錄: + - `stage=preparing` event 第一次出現的 timestamp + - `stage=done` event 出現的 timestamp + - 差距 = 升級實測時長 +3. 對照表格: + +| Combo | 預期時長 | 實測時長 | Pass / Fail | +|-------|---------|---------|------------| +| macOS × KL520 × KDP1→KDP2 | < 60s | | | +| macOS × KL520 × short-circuit | < 60s | | | +| macOS × KL720 | < 200s | | | +| Windows × KL520 × KDP1→KDP2 | < 60s | | | +| ... | | | | + +**Pass 條件**:所有 combo 升級時長 ≤ 護欄上界。 +**Warn 條件**:時長 > 預估但 < 護欄、log 為 P3 改善項。 +**Fail 條件**:時長 ≥ 護欄、阻擋發布、回 Backend 派工調 timeout / 修流程。 + +### 6.3 progress event 推送頻率 + +- 預期:每 stage 切換時推 1 個 event、每秒至少 1 個 event(含 ETA 更新) +- 若 stage 間 > 10s 沒有 event:UI ETA 失準、log 為 Backend P2 改善項 + +--- + +## 7. UX 驗證 + +### 7.1 Badge 4 色狀態(AC-FW-1.1) + +| State | 條件 | 顏色 | 文字 | +|-------|------|------|------| +| current | firmwareVersion = bundled current | 綠 | 版本字串(如 `v2.2.0`) | +| older | firmwareVersion 比 current 舊(同 KDP2 系列)| 黃 | 版本字串 | +| legacy | firmwareIsLegacy = true(KDP1)| 紅 | `KDP1` | +| unknown | firmwareVersion 或 bundled 為空 / `unknown` | 灰 | `Unknown` 或 `Loading...` | + +**測試步驟**: +1. 在三平台分別跑:插一根 KDP1 legacy dongle、確認紅 badge +2. 升級到 v2.2.0 後、確認綠 badge +3. 修改 bundle CURRENT_VERSION(如果 A 階段沒實作則此步驟跳過、由 B 階段 M9-9 驗)→ 確認黃 badge +4. 拔 USB → 卡片消失(不該顯示灰 badge、直接 device 不在清單) +5. 連線中 bridge.py 沒回 firmware 字串 → 顯示灰 badge(過渡狀態) + +### 7.2 升級 modal R-FW-11「不可關」驗證(AC-FW-1.9) + +升級 upgrading phase 期間驗證: +- [ ] ✕ 按鈕不存在(DialogContent showCloseButton={false}) +- [ ] ESC 不關閉 modal +- [ ] 點 modal 外部(黑色 overlay)不關閉 +- [ ] 「取消」按鈕不存在 +- [ ] 即使刷新瀏覽器頁面後再回來、升級期間仍在進行(progress modal 由 store 重新 hydrate) + +### 7.3 失敗 modal 復原 UI(Design §7.2) + +| Reason | 主要按鈕 | 次要按鈕 | +|--------|---------|---------| +| scan_not_found | 「重新插拔後重試」 | 關閉 | +| connect_failed | 「重試」 | 關閉 | +| loader_write_failed | 「拔插後重試」 | 關閉 | +| upgrade_mid_failed | 「重試」 | 關閉 | +| timeout | 「拔插後重新掃描」 | 關閉 | +| disconnect_during_op | **「Contact Support」**(destructive、mailto:) | 關閉、**無 retry** | +| verify_mismatch | **「Contact Support」** | 關閉、**無 retry** | +| verify_not_found | **「Contact Support」** | 關閉、**無 retry** | + +每種失敗都驗: +- [ ] 主要按鈕文案正確 +- [ ] destructive reason 不顯示 Retry +- [ ] Contact Support 點擊開 mailto: handler(subject + body 帶 errorCode + 技術資訊) +- [ ] 「複製錯誤訊息」可複製、變「已複製 ✓」2 秒後復原 +- [ ] errorCode 以 mono 字型顯示 + +### 7.4 Success toast 行為 + +- [ ] toast 顯示「{deviceName} 升級成功 — 從 X 升級到 Y(耗時 N 秒)」 +- [ ] toast 停留 6 秒(不是預設 4 秒) +- [ ] toast 自動消失、不需手動關 +- [ ] 升級成功後 Devices 頁自動 refresh、badge 變綠 + +--- + +## 8. 整合驗證(Integration) + +### 8.1 升級期間 SIGTERM 延遲關閉(TDD §8.6.1) + +**測試步驟**: + +1. macOS / Linux:找出 server PID(`ps aux | grep visiona-local-server` 或 visionA-local 控制台顯示) +2. 啟動升級流程(情境 A 或 C、長一點的) +3. 升級到 flashing stage 時、執行 `kill -TERM ` +4. 預期: + - server 不立即退出 + - 透過 WS 廣播 `server:shutdown-pending` event 到 `"system"` room + - shutdown 延遲到 firmware task 完成(最多 `firmware.MaxShutdownWait = 220s`) + - firmware task 跑完才走原本 graceful shutdown 流程 +5. 觀察 server log、應有: + - `firmware: N active firmware task(s) detected, delaying shutdown up to ...` + - firmware task 完成後、`firmware: all firmware tasks completed, proceeding to shutdown` +6. 升級結束後、server 才真正退出 +7. Frontend modal 在 server 退出前該完成正常的 upgrade success toast 流程 + +**Pass 條件**: +- ✅ server 不在升級進行中退出(不 brick) +- ✅ SIGTERM event log 有 `delaying shutdown` 紀錄 +- ✅ 升級成功完成、device 升級後狀態正常 + +### 8.2 Wails OnBeforeClose force-quit modal(TDD §8.6.2 + visiona-local/firmware_close_guard.go) + +**測試步驟**: + +1. 任一平台 + 任一 chip +2. 啟動升級流程(情境 A 或 C) +3. 升級到 flashing stage 時、點 Wails 視窗的 ✕ 關閉按鈕(macOS:紅色關閉、Windows:右上 ✕、Linux:window manager close) +4. 預期: + - Wails 視窗 **不關閉** + - Wails 收到 `OnBeforeClose` 後查 `/api/firmware/active-tasks` → `hasActive: true` + - emit Wails event `app:firmware-in-progress` 帶 task payload + - Frontend modal 攔截、顯示「韌體切換進行中、為避免裝置損毀、無法關閉應用程式」 + - modal 顯示 task info:device name / chip / direction / stage / elapsedMs / etaSeconds + - modal 顯示「繼續等待」(primary、回到原本升級流程)+ 「強制關閉」(destructive)按鈕 +5. 點「強制關閉」、預期第二層 FORCE 確認 modal(要使用者輸入「FORCE」字串) +6. 不輸入 / 取消 → 升級繼續、modal 關閉、Wails 視窗回到正常 +7. 輸入「FORCE」+ 確認 → `ConfirmForceClose` binding 被叫、Wails 走 graceful shutdown(會 brick 裝置、風險已接受、Design §6a) +8. 升級正常結束(不點強制關閉的情境)後、再點 ✕ → Wails 正常關閉 + +**Pass 條件**: +- ✅ 升級進行中視窗無法關 +- ✅ force-quit modal 顯示 + payload 正確 +- ✅ 第二層 FORCE 確認可運作 +- ✅ 升級完成後視窗正常可關 + +### 8.3 升級期間 server crash 容忍 + +**測試步驟**: + +1. 升級進行中、`kill -9 `(強制殺、不走 graceful shutdown) +2. Frontend WS 應於 3 秒內偵測到斷線、自動重連嘗試 +3. server 重啟後(手動 / Wails 自動)、Frontend bootId 機制應觸發 reload +4. Devices 頁應重新 scan、卡片回到「device 連線中」或「沒有 device」狀態 +5. 觀察 dongle 實體狀態 — 可能 brick(取決於升級 stage、屬接受風險、TDD §8.6.1 hard timeout 後也走 shutdown) + +**Pass 條件**: +- ✅ Frontend 不卡死、UI 可繼續操作 +- ✅ Server 重啟後狀態正常 +- ⚠ Dongle 可能 brick(屬接受風險、需給技術支援 SOP 救磚) + +--- + +## 9. 自動化腳本對照表 + +| 測試類型 | 涵蓋方式 | 對應檔 | +|---------|---------|--------| +| Backend smoke schema 對齊(MJ3) | 已修、go test 全綠 | `server/internal/api/ws/firmware_ws_test.go` | +| Frontend store 邏輯 | 已有 vitest 60 tests 全綠 | `frontend/src/tests/stores/firmware-store.test.ts` | +| Frontend component(badge / error view) | 已有 vitest 全綠 | `frontend/src/tests/components/firmware-*.test.tsx` | +| **E2E happy path(mock WS + UI)** | M9-5 新增 vitest 風格 | `.autoflow/06-testing/scripts/firmware-upgrade-happy-path.spec.ts` | +| **E2E 失敗注入(8 reason × UI)** | M9-5 新增 vitest 風格 | `.autoflow/06-testing/scripts/firmware-upgrade-error-recovery.spec.ts` | +| **E2E R-FW-11 modal 不可關** | M9-5 新增 vitest 風格 | `.autoflow/06-testing/scripts/firmware-r-fw-11-modal-not-closable.spec.ts` | +| **E2E Wails OnBeforeClose 攔截邏輯** | M9-5 新增(unit-style on Go) | `.autoflow/06-testing/scripts/wails-onbeforeclose-firmware-active.spec.ts` | +| 三平台實機驗證 | 人工 checklist | `.autoflow/06-testing/scripts/manual-checklist-{macos,windows,linux}.md` | + +**注意**:因為 visionA-local 沒有 Playwright / Cypress、實機驗證需要實體 dongle、E2E 腳本走 **vitest + RTL + mock WS / mock fetch** 路線,模擬 backend 整條 stage 流轉、frontend 跑真實 component build。實機驗證走人工 checklist(§12)。 + +--- + +## 10. 通過 / 失敗判定規則 + +### 10.1 P0(阻擋發布) + +任一發生 → A 階段不可進入發布流程: +- Functional 升級成功率 < 90%(24 combo 中 ≥ 3 個失敗) +- 升級期間 server crash / panic +- 升級期間 dongle 100% brick(無法復原) +- §8.1 SIGTERM 期間 server 不延遲關閉(直接中斷 firmware task) +- §8.2 Wails 視窗在升級進行中可直接關閉(OnBeforeClose 沒生效) + +### 10.2 P1(嚴重但可繞過) + +任一發生 → 開 P1 bug ticket、視 release window 決定是否阻擋: +- 升級時長 > 護欄但 < 護欄 × 1.5(KL520 60-90s / KL720 200-300s) +- Frontend store 多裝置隔離失效(兩個 progress modal 互相覆蓋) +- destructive reason 仍顯示 retry 按鈕 +- force-quit modal 第二層 FORCE 確認字串可繞過 + +### 10.3 P2(一般問題) + +- Badge 顏色顯示錯誤(如 older 顯示成 current) +- toast 持續時間錯誤 +- i18n key 漏譯 / 文案錯誤 + +### 10.4 P3(改善建議) + +- progress event 推送頻率太低(卡頓 > 5s) +- error modal 「技術資訊」展開後排版錯亂 + +--- + +## 11. Bug 回報格式 + +對每個發現的問題,使用以下格式回報到 `.autoflow/06-testing/bugs/M9-5-BUG-{NN}.md`: + +```markdown +# M9-5-BUG-NN: [標題] + +## 嚴重程度:P0 / P1 / P2 / P3 +## 發現方式:Functional / Reliability / Performance / UX / Integration +## 環境:macOS 14.3 / Windows 11 / Ubuntu 22.04 +## 對應驗證:§4.1 情境 A / §5.1 #3 loader_write_failed / etc. +## 對應 AC:AC-FW-1.X / R-FW-11 / R-FW-13 + +## 重現步驟 +1. ... +2. ... + +## 預期行為 +(依 PRD / TDD / Design Spec、引用具體章節) + +## 實際行為 +(觀察到的差異) + +## 截圖 +(路徑:.autoflow/06-testing/screenshots/M9-5-bug-NN-{step}.png) + +## Server log 片段 +``` +(從 ~/Library/Application Support/visiona-local/wails.log 或對應位置) +``` + +## Frontend WS event log +``` +(從 browser DevTools Network → WS frames) +``` + +## 建議修復方向(如有) +(不一定要、但 testing 觀察過程的線索) +``` + +--- + +## 12. 下週執行建議順序(給使用者) + +### 12.1 第 1 天:smoke + happy path(必驗、~3 小時) + +優先順序: +1. **macOS × KL520 × KDP2 short-circuit**(最快、不需 KDP1 dongle、5 分鐘) +2. **macOS × KL520 × KDP1 → KDP2 完整升級**(happy path 核心、10 分鐘) +3. **macOS × KL720 × 升級**(單一 chip 驗證、5 分鐘 + 3 分鐘升級 = 10 分鐘) +4. 重複 1-3 在 Windows +5. 重複 1-3 在 Linux + +→ 若 macOS 1-3 全綠、且 Windows / Linux 至少 1 個 combo 綠、可進入第 2 天。 +→ 若 macOS 任一失敗:停下、回報 bug、不繼續其他平台。 + +### 12.2 第 2 天:reliability + UX(4 小時) + +對 macOS(或第 1 天最穩的平台)跑: +1. R-FW-11 modal 不可關(5 分鐘) +2. Badge 4 色顯示(10 分鐘) +3. 失敗注入 #1 scan_not_found(5 分鐘) +4. 失敗注入 #3 loader_write_failed(10 分鐘) +5. 失敗注入 #4 upgrade_mid_failed(10 分鐘) +6. 失敗注入 #5 disconnect_during_op(10 分鐘) +7. 失敗注入 #6 timeout(10 分鐘) +8. UX checklist:toast / 復原 UI / mailto: 開啟(30 分鐘) + +再對 Windows + Linux 跑 #5 disconnect_during_op + #4 upgrade_mid_failed(兩個 brick risk 最高的)作為 sanity check。 + +### 12.3 第 3 天:integration(2-3 小時) + +對任一最穩平台跑: +1. §8.1 SIGTERM 延遲關閉(macOS / Linux 推薦、Windows SIGTERM 不直觀) +2. §8.2 Wails OnBeforeClose force-quit modal(三平台都要驗) + +### 12.4 整體建議優先順序 + +``` +P0 must-have(必跑): + - §4.1 情境 A × 三平台(核心 happy path) + - §4.1 情境 B × 三平台(short-circuit 驗證) + - §8.2 Wails OnBeforeClose × 三平台(避免 brick) + +P1 should-have(強烈建議): + - §4.1 情境 C × 三平台(KL720 驗證) + - §5.1 #5 disconnect_during_op × 三平台(brick 風險最高) + - §8.1 SIGTERM × macOS + Linux + - §7.3 失敗 modal 復原 UI × 至少一個平台 + +P2 nice-to-have(時間允許): + - §4.1 情境 D 來回升級 × 任一平台 + - §5.1 #1-4 + #6 其他失敗注入 + - §6 effective 時長量測 +``` + +--- + +## 13. Pass → Next-step / Fail → 派工建議 + +### 13.1 Pass(所有 P0 都綠 + P1 大部分綠) + +→ A 階段可進入發布流程: +1. 給 Orchestrator 「A 階段交付」訊號 +2. Orchestrator 啟動 Reviewer agent 對 M9-5 plan + scripts + manual checklist 做最終 review +3. Reviewer 通過後、A 階段五人天宣告完成、可進入: + - 法律合規確認(Kneron firmware redistribution 授權、R-FW-5 / Q-FW-1) + - 發布 A 階段 visionA-local(含 KL520/KL720 升級 only、KL630/KL730 延 B 階段) + - **或** 進入 B 階段 M9-6 強驗證 + M9-7 啟動 + +### 13.2 Fail(任一 P0 失敗 / 多個 P1 失敗) + +依失敗類型分流派工建議: + +| Fail 類型 | 派誰 | +|----------|------| +| §4.1 升級流程崩潰、stage 不正確 | Backend(修 firmware_handler / bridge.py / driver) | +| §5 reason 對應錯誤、UI friendly message 沒顯示 | Frontend(修 firmware-store errorMessageKeyFor / firmware-error-view) | +| §6 時長 ≥ 護欄 | Backend(調 bridge.py timeout 或優化流程) | +| §7.1 Badge 4 色錯誤 | Frontend(修 firmware-badge computeBadgeState) | +| §7.2 R-FW-11 modal 可關 | Frontend(修 firmware-upgrade-dialog onOpenChange) | +| §8.1 SIGTERM 不延遲 | Backend(修 firmware/shutdown.go + server/main.go signal handler) | +| §8.2 Wails 視窗可關 | Wails layer(修 visiona-local/firmware_close_guard.go + frontend modal) | + +**回報給 Orchestrator 的 escalation 範本**: + +``` +[M9-5 驗證失敗] +- 失敗 combo:[macOS × KL520 × KDP1→KDP2 / 情境 A] +- 失敗類型:[P0 升級流程崩潰] +- bug ticket:.autoflow/06-testing/bugs/M9-5-BUG-NN.md +- 建議派工:Backend(修 bridge.py upgrade_mid 處理) +- A 階段交付狀態:阻擋 +``` + +--- + +## 14. 附錄 + +### 14.1 重要 log 位置 + +| 平台 | wails.log | server.log | +|------|-----------|-----------| +| macOS | `~/Library/Application Support/visiona-local/wails.log` | `~/Library/Application Support/visiona-local/server.log` | +| Linux | `~/.local/share/visiona-local/wails.log` | `~/.local/share/visiona-local/server.log` | +| Windows | `%APPDATA%\visiona-local\wails.log` | `%APPDATA%\visiona-local\server.log` | + +### 14.2 重要 WS endpoint + +- `ws://127.0.0.1:/ws/devices/:id/firmware-progress` — firmware progress room +- broadcast `type=firmware_progress`、`stage=preparing|loading|flashing|verifying|done|error` + +### 14.3 重要 API endpoint + +- `POST /api/devices/:id/firmware/upgrade` — 觸發升級 +- `GET /api/firmware/active-tasks` — 查詢進行中 task(Wails 用) + +### 14.4 主要相依檔(給 bug 回報時引用) + +| 檔 | 角色 | +|----|------| +| `server/internal/firmware/service.go` | 升級 service goroutine | +| `server/internal/firmware/shutdown.go` | SIGTERM graceful shutdown | +| `server/internal/api/handlers/firmware_handler.go` | API + WS broadcast | +| `server/scripts/kneron_bridge.py` | bridge.py upgrade handler | +| `visiona-local/firmware_close_guard.go` | Wails OnBeforeClose | +| `frontend/src/components/firmware/firmware-upgrade-dialog.tsx` | modal lifecycle | +| `frontend/src/stores/firmware-store.ts` | phase / reason mapping | + +--- + +## 變更紀錄 + +| 日期 | 版本 | 變更 | 作者 | +|------|------|------|------| +| 2026-05-25 | v1.0 | 初版 — M9-5 三平台實機驗證 plan + 自動化腳本對照表 + 人工 checklist 索引 + 下週執行建議順序 | Testing Agent | diff --git a/local-tool/.autoflow/06-testing/scripts/firmware-r-fw-11-modal-not-closable.spec.ts b/local-tool/.autoflow/06-testing/scripts/firmware-r-fw-11-modal-not-closable.spec.ts new file mode 100644 index 0000000..0562afb --- /dev/null +++ b/local-tool/.autoflow/06-testing/scripts/firmware-r-fw-11-modal-not-closable.spec.ts @@ -0,0 +1,303 @@ +/** + * 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( + , + ); + + 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(); + + // 直接看 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(); + + // 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(); + + 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' && (
⚠ 請勿拔除裝置
)} + 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 即可 + }); +}); diff --git a/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-error-recovery.spec.ts b/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-error-recovery.spec.ts new file mode 100644 index 0000000..42eea0a --- /dev/null +++ b/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-error-recovery.spec.ts @@ -0,0 +1,356 @@ +/** + * M9-5 E2E #2 — Firmware Upgrade Error Recovery + * + * 目的:對 Design Spec §7.1 列出的 8 種失敗 reason 注入 progress event、驗 UI + * 對應 friendly message + 復原行動按鈕。 + * + * 涵蓋 AC: + * - AC-FW-1.4 升級失敗時 modal 顯示明確失敗類型 + 複製錯誤訊息 + 重試指引 + * - R-FW-7 升級失敗 device 不該卡死 + * - R-FW-11/12 destructive reason 必須顯示 brick warning + 不可 retry + * + * Reason 對應 8 種 friendly message: + * 1. scan_not_found → settings.firmware.error.message.scanNotFound + * 2. connect_failed → settings.firmware.error.message.connectFailed + * 3. loader_write_failed → settings.firmware.error.message.loaderWriteFailed + * 4. upgrade_mid_failed → settings.firmware.error.message.upgradeMidFailed + * 5. verify_mismatch → settings.firmware.error.message.verifyMismatch (destructive) + * 6. verify_not_found → settings.firmware.error.message.verifyNotFound (destructive) + * 7. timeout → settings.firmware.error.message.timeout + * 8. disconnect_during_op → settings.firmware.error.message.disconnect (destructive) + * + * 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, FirmwareReason } from '@/types/device'; + +// ────────────────────────────────────────────────────────────────────── +// Test fixtures +// ────────────────────────────────────────────────────────────────────── + +function makeLegacyKL520(overrides: Partial = {}): 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, + }; +} + +class MockWebSocket { + static instances: MockWebSocket[] = []; + static OPEN = 1; + static CLOSED = 3; + 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 = MockWebSocket.CLOSED; + 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 setupAtUpgradingPhase(device = makeLegacyKL520()) { + const onOpenChange = vi.fn(); + render(); + + const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ }); + await act(async () => { + fireEvent.click(startBtn); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(useFirmwareStore.getState().phase).toBe('upgrading'); + }); + + return { + ws: MockWebSocket.instances[MockWebSocket.instances.length - 1], + onOpenChange, + }; +} + +// ────────────────────────────────────────────────────────────────────── +// 8 種 reason × UI 驗證 +// ────────────────────────────────────────────────────────────────────── + +interface ReasonCase { + reason: FirmwareReason; + stage: FirmwareProgressEvent['stage']; + isDestructive: boolean; + expectedButtonPattern: RegExp; + description: string; +} + +const REASON_CASES: ReasonCase[] = [ + { + reason: 'scan_not_found', + stage: 'preparing', + isDestructive: false, + expectedButtonPattern: /Unplug and retry|插拔/, + description: 'scan_not_found → ReplugRetry button', + }, + { + reason: 'connect_failed', + stage: 'preparing', + isDestructive: false, + expectedButtonPattern: /Retry|重試/, + description: 'connect_failed → Retry button', + }, + { + reason: 'loader_write_failed', + stage: 'loading', + isDestructive: false, + expectedButtonPattern: /Retry|重試/, + description: 'loader_write_failed → Retry button', + }, + { + reason: 'upgrade_mid_failed', + stage: 'flashing', + isDestructive: false, + expectedButtonPattern: /Retry|重試/, + description: 'upgrade_mid_failed → Retry button', + }, + { + reason: 'timeout', + stage: 'flashing', + isDestructive: false, + expectedButtonPattern: /Unplug and rescan|拔插.*掃描/, + description: 'timeout → Rescan button', + }, + { + reason: 'disconnect_during_op', + stage: 'flashing', + isDestructive: true, + expectedButtonPattern: /Contact|Support|聯絡|技術支援/, + description: 'disconnect_during_op → Contact Support(destructive)', + }, + { + reason: 'verify_mismatch', + stage: 'verifying', + isDestructive: true, + expectedButtonPattern: /Contact|Support|聯絡|技術支援/, + description: 'verify_mismatch → Contact Support(destructive)', + }, + { + reason: 'verify_not_found', + stage: 'verifying', + isDestructive: true, + expectedButtonPattern: /Contact|Support|聯絡|技術支援/, + description: 'verify_not_found → Contact Support(destructive)', + }, +]; + +describe('M9-5 E2E #2: 8 種失敗 reason 對應 friendly message + 復原 UI', () => { + beforeEach(() => { + installMockWebSocket(); + installMockFetch(); + useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null }); + vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + it.each(REASON_CASES)( + '$description', + async ({ reason, stage, isDestructive, expectedButtonPattern }) => { + const { ws } = await setupAtUpgradingPhase(); + + // 推 error event + act(() => { + ws.pushMessage({ + type: 'firmware_progress', + deviceId: 'kl520-0', + stage: 'error', + percent: -1, + elapsedMs: 5000, + reason, + error: `simulated ${reason} failure`, + rawError: `bridge.py raised ${reason}`, + errorCode: `FW_${reason.toUpperCase()}_E001`, + }); + }); + + await waitFor(() => { + expect(useFirmwareStore.getState().phase).toBe('error'); + }); + + // 1. error modal 開啟 + expect(screen.getByRole('alertdialog')).toBeTruthy(); + + // 2. errorCode 顯示 + const errorCode = `FW_${reason.toUpperCase()}_E001`; + expect(screen.getAllByText(new RegExp(errorCode)).length).toBeGreaterThanOrEqual(1); + + // 3. destructive vs recoverable + if (isDestructive) { + // brick warning role=note 存在 + expect(screen.getByRole('note')).toBeTruthy(); + // 沒有 retry 按鈕 + const buttons = screen.getAllByRole('button'); + const hasRetry = buttons.some((b) => /Retry|重試/.test(b.textContent || '')); + expect(hasRetry).toBe(false); + } else { + // 沒有 brick warning + expect(screen.queryByRole('note')).toBeNull(); + } + + // 4. 主要按鈕對應 pattern + const primaryBtn = screen + .getAllByRole('button') + .find((b) => expectedButtonPattern.test(b.textContent || '')); + expect(primaryBtn).toBeTruthy(); + }, + ); + + it('Contact Support 按鈕點擊開 mailto: 並帶上技術資訊', async () => { + const { ws } = await setupAtUpgradingPhase(); + + act(() => { + ws.pushMessage({ + type: 'firmware_progress', + deviceId: 'kl520-0', + stage: 'error', + percent: -1, + elapsedMs: 8000, + reason: 'verify_mismatch', + rawError: 'kp_update_kdp_firmware: version mismatch', + errorCode: 'FW_VERIFY_MISMATCH_E201', + }); + }); + + await waitFor(() => { + expect(useFirmwareStore.getState().phase).toBe('error'); + }); + + const contactBtn = screen + .getAllByRole('button') + .find((b) => /Contact|Support|聯絡|技術支援/.test(b.textContent || '')); + expect(contactBtn).toBeTruthy(); + + fireEvent.click(contactBtn!); + expect(window.open).toHaveBeenCalled(); + const [href] = (window.open as ReturnType).mock.calls[0]; + expect(href).toMatch(/^mailto:/); + // 應帶 errorCode 與 reason + const decoded = decodeURIComponent(href); + expect(decoded).toContain('FW_VERIFY_MISMATCH_E201'); + expect(decoded).toContain('reason: verify_mismatch'); + }); + + it('Retry 按鈕點擊後 store 回 confirming phase(準備重新升級)', async () => { + const { ws } = await setupAtUpgradingPhase(); + + act(() => { + ws.pushMessage({ + type: 'firmware_progress', + deviceId: 'kl520-0', + stage: 'error', + percent: -1, + elapsedMs: 3000, + reason: 'connect_failed', + error: 'cannot connect to device', + }); + }); + + await waitFor(() => { + expect(useFirmwareStore.getState().phase).toBe('error'); + }); + + const retryBtn = screen + .getAllByRole('button') + .find((b) => /Retry|重試/.test(b.textContent || '')); + expect(retryBtn).toBeTruthy(); + + // 點 retry → dialog handleRetry → setState confirming → handleStart 重連 WS + await act(async () => { + fireEvent.click(retryBtn!); + await Promise.resolve(); + await Promise.resolve(); + }); + + // 走 handleStart 後 phase 又回 upgrading + // (retry 成功 → store 進 upgrading;失敗 → 進 error) + const phaseAfterRetry = useFirmwareStore.getState().phase; + expect(['confirming', 'upgrading']).toContain(phaseAfterRetry); + }); + + it('複製錯誤訊息按鈕 → navigator.clipboard.writeText 被叫', async () => { + // jsdom 預設沒 clipboard、注入 mock + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: writeTextMock }, + }); + + const { ws } = await setupAtUpgradingPhase(); + act(() => { + ws.pushMessage({ + type: 'firmware_progress', + deviceId: 'kl520-0', + stage: 'error', + percent: -1, + elapsedMs: 5000, + reason: 'upgrade_mid_failed', + rawError: 'flash write failed at offset 0x1000', + errorCode: 'FW_UPGRADE_MID_E102', + }); + }); + + await waitFor(() => { + expect(useFirmwareStore.getState().phase).toBe('error'); + }); + + const copyBtn = screen.getByRole('button', { name: /Copy|複製/ }); + fireEvent.click(copyBtn); + + expect(writeTextMock).toHaveBeenCalled(); + const text = writeTextMock.mock.calls[0][0]; + expect(text).toContain('stage: error'); + expect(text).toContain('reason: upgrade_mid_failed'); + expect(text).toContain('errorCode: FW_UPGRADE_MID_E102'); + expect(text).toContain('rawError: flash write failed at offset 0x1000'); + }); +}); diff --git a/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-happy-path.spec.ts b/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-happy-path.spec.ts new file mode 100644 index 0000000..94839ed --- /dev/null +++ b/local-tool/.autoflow/06-testing/scripts/firmware-upgrade-happy-path.spec.ts @@ -0,0 +1,357 @@ +/** + * 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 { + 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 { + 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 { + 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(); + + // 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(); + + 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( {}} />); + + 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( {}} />); + + 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'); + }); +}); diff --git a/local-tool/.autoflow/06-testing/scripts/manual-checklist-linux.md b/local-tool/.autoflow/06-testing/scripts/manual-checklist-linux.md new file mode 100644 index 0000000..b05fe32 --- /dev/null +++ b/local-tool/.autoflow/06-testing/scripts/manual-checklist-linux.md @@ -0,0 +1,243 @@ +# M9-5 Linux 實機驗證 checklist + +> 對應 plan:`../m9-5-validation-plan.md` +> 平台:Ubuntu 20.04+ / Debian 11+ / 同等 distro(x86_64) +> 預估時間:3-4 小時 +> 執行人:使用者(下週) + +--- + +## 0. 前置準備(15 分鐘) + +- [ ] 確認 distro 版本:`lsb_release -a` 或 `cat /etc/os-release`、紀錄:__________ +- [ ] visionA-local A 階段 build(含 M9-1 ~ M9-4)已安裝 +- [ ] 清空舊狀態:`rm -rf ~/.local/share/visiona-local/ ~/.config/visiona-local/` +- [ ] 準備硬體: + - [ ] KL520 KDP1 legacy dongle:__________ + - [ ] KL520 KDP2 dongle:__________ + - [ ] KL720 dongle:__________ +- [ ] **udev rules 已綁定**(KneronPLUS Linux 必要): + - [ ] 確認 `/etc/udev/rules.d/` 下有 KneronPLUS rule(如 `99-kneron-plus.rules`) + - [ ] 確認當前使用者在 `plugdev` group:`groups | grep plugdev` + - [ ] 若無、跑 installer / `sudo usermod -aG plugdev $USER`、登出再登入 +- [ ] Chrome / Firefox 任一最新版開啟、F12 DevTools 準備好 +- [ ] Terminal 開好、`tail -f ~/.local/share/visiona-local/server.log` + +--- + +## 1. Functional 升級流程驗證(90 分鐘) + +### 1.1 情境 A:KL520 KDP1 → KDP2 完整 4-stage(30 分鐘) + +**前置**:插 KL520 KDP1 dongle + +- [ ] `lsusb` 確認 dongle 被 USB stack 認到(找 `Kneron` 或 `0c45:` PID) +- [ ] 啟動 visionA-local(執行 `./visiona-local` 或 .desktop launcher) +- [ ] Wails 視窗開啟、控制台顯示 server URL +- [ ] 點「開啟瀏覽器」、Devices 頁出現 +- [ ] **紅色 badge** `KDP1 (legacy)` 顯示 +- [ ] 點升級、confirm modal +- [ ] from/to 顯示 from = `KDP1`、to = `v2.2.0` +- [ ] 點「開始升級」、upgrading phase +- [ ] **progress modal 不顯示 ✕ / 取消** +- [ ] 紅色 banner「請勿拔除裝置」顯示 +- [ ] stage 序列:preparing → loading → flashing → verifying +- [ ] ESC 不關 modal、點外部不關 +- [ ] 升級在 60 秒內完成(**實測時長**:______ 秒) +- [ ] modal 顯示綠勾、自動關閉、toast 顯示 6 秒 +- [ ] Devices 卡片 badge 變綠 + `v2.2.0` +- [ ] `journalctl --user -u visiona-local` 或 server.log 無 panic + +**截圖**: +- [ ] `.autoflow/06-testing/screenshots/linux-A-before.png` +- [ ] `.autoflow/06-testing/screenshots/linux-A-upgrading.png` +- [ ] `.autoflow/06-testing/screenshots/linux-A-success.png` + +### 1.2 情境 B:KL520 KDP2 short-circuit(15 分鐘) + +- [ ] 黃色 badge / 點升級 +- [ ] **3 stage 序列** +- [ ] 60 秒內完成(**實測**:______ 秒) + +### 1.3 情境 C:KL720 升級(15 分鐘) + +- [ ] 點升級、confirm 顯示「3 分鐘」 +- [ ] 3 stage +- [ ] 200 秒內完成(**實測**:______ 秒) + +### 1.4 情境 D:來回升級(15 分鐘) + +- [ ] 重跑情境 A 一次 +- [ ] **無 Error 15** +- [ ] 兩次都成功 + +### 1.5 多裝置同時連接(15 分鐘) + +- [ ] KL520 + KL720 同時插 +- [ ] 兩張卡片 +- [ ] KL520 升級時、KL720 升級被擋 +- [ ] KL520 完成後 KL720 可升級 + +--- + +## 2. Reliability 失敗注入(45 分鐘) + +### 2.1 #1 scan_not_found(5 分鐘) + +- [ ] 拔 USB、點升級 +- [ ] error modal「找不到裝置」 + +### 2.2 #3 loader_write_failed(10 分鐘) + +**注入**:loading stage 時、`pkill -9 python3` 或 `pkill -9 -f kneron_bridge.py` + +- [ ] error modal「引導程式載入失敗」 +- [ ] 「拔插後重試」按鈕 +- [ ] **無 brick warning** + +### 2.3 #4 upgrade_mid_failed(10 分鐘) + +**注入**:flashing stage 時、`pkill -9 python3` + +- [ ] error modal「韌體寫入失敗」 +- [ ] errorCode `FW_UPGRADE_*` +- [ ] 「重試」按鈕 + +### 2.4 #5 disconnect_during_op(10 分鐘) + +**注入**:flashing stage 時、**拔 USB** + +- [ ] error modal「裝置已斷開」 +- [ ] **brick warning role=note** +- [ ] **無 Retry 按鈕** +- [ ] **Contact Support destructive 按鈕** +- [ ] 點 Contact Support、`xdg-open` 預設 mail handler 應開(如 Thunderbird) + +### 2.5 #6 timeout(10 分鐘) + +- [ ] 拔 USB 後等 > 60s(KL520)/ > 200s(KL720) +- [ ] error modal「操作超時」 + +--- + +## 3. UX 驗證(30 分鐘) + +### 3.1 Badge 4 色 + +| State | 觸發 | 紀錄 | +|-------|------|------| +| current(綠) | | [ ] | +| older(黃) | | [ ] | +| legacy(紅) | KDP1 | [ ] | +| unknown(灰) | scan 過渡期 | [ ] | + +### 3.2 Modal 警告 banner 全程顯示 + +- [ ] 4 個 stage 都顯示「請勿拔除裝置」 + +### 3.3 Toast 停留 6 秒 + +- [ ] 計時驗證 + +### 3.4 複製錯誤訊息 + +- [ ] 點複製、變「已複製 ✓」 +- [ ] `xclip -selection clipboard -o` 確認內容含 stage / reason / errorCode / rawError + +### 3.5 mailto: 開啟 + +- [ ] destructive reason 點 Contact Support +- [ ] Thunderbird / Evolution / 預設 mail client 開啟(透過 `xdg-open` 或 `gio open`) +- [ ] 若無預設 mail client → log 為 P3、不算 fail + +--- + +## 4. Integration 驗證(30 分鐘) + +### 4.1 SIGTERM 延遲關閉(**Linux 最直觀**、推薦在此平台驗) + +**前置**:終端機跑 `pgrep -f visiona-local-server`、紀錄 PID + +- [ ] 啟動升級流程(情境 A 慢一點) +- [ ] 升級進入 flashing stage 時、Terminal 跑 `kill -TERM ` +- [ ] **server 不立即退出** +- [ ] `tail -f ~/.local/share/visiona-local/server.log`: + - [ ] log 含「firmware: N active firmware task(s) detected, delaying shutdown up to 220s」 +- [ ] 升級正常完成 +- [ ] log 含「firmware: all firmware tasks completed, proceeding to shutdown」 +- [ ] server 完成升級後才退出 +- [ ] Wails 視窗收到 server offline overlay + +### 4.2 Wails OnBeforeClose 攔截 + +- [ ] 啟動升級 +- [ ] flashing stage、點 Wails 視窗 ✕(window manager 的關閉按鈕) +- [ ] **視窗不關** +- [ ] Frontend modal「韌體切換進行中...」 +- [ ] modal 顯示 task info +- [ ] 「繼續等待」+「強制關閉」按鈕 +- [ ] 點強制關閉、第二層 FORCE 確認 +- [ ] 輸入「force」小寫 → disabled +- [ ] 輸入「FORCE」 → enabled +- [ ] 點「繼續等待」→ modal 關、升級繼續 +- [ ] 升級完成、再點 ✕ → 視窗正常關 + +### 4.3 SIGINT(Ctrl+C)測試 + +⚠ Linux 特有、若 visionA-local 從 terminal 跑: + +- [ ] terminal 跑 visionA-local +- [ ] 升級進行中、Ctrl+C +- [ ] server SIGINT handler 應視同 SIGTERM、走 delayed shutdown +- [ ] 升級完成才退出 + +--- + +## 5. Linux 特有觀察點 + +### 5.1 USB 權限 + +- [ ] `dmesg | tail` 升級期間沒 USB permission denied +- [ ] device re-enumerate 後 udev rules 自動套用 + +### 5.2 systemd / desktop integration + +- [ ] visiona-local.desktop 若有、launcher 啟動正常 +- [ ] gnome-shell / kwin 對 Wails 視窗的 close button 處理正確 + +### 5.3 server.log 路徑 + +依 distro 可能在: +- `~/.local/share/visiona-local/server.log`(XDG_DATA_HOME) +- `~/.config/visiona-local/server.log` +- 或 systemd journal(`journalctl --user`) + +確認 log 寫入位置:__________ + +--- + +## 6. 驗證結果摘要 + +| 類別 | 結果 | 備註 | +|------|------|------| +| §1 Functional | | | +| §2 Reliability | | | +| §3 UX | | | +| §4 Integration | | | +| §5 Linux 特有 | | | + +**升級時長實測**: + +| Combo | 實測 | 護欄 | Pass / Fail | +|-------|------|------|-----------| +| KL520 KDP1→KDP2 | __秒 | 60s | | +| KL520 short-circuit | __秒 | 60s | | +| KL720 | __秒 | 200s | | + +--- + +## 7. Bug 回報 + +依 `../m9-5-validation-plan.md §11` 格式、標題前綴「[Linux]」+ distro 版本。 + +**完成簽核**:________________ 日期:________________ diff --git a/local-tool/.autoflow/06-testing/scripts/manual-checklist-macos.md b/local-tool/.autoflow/06-testing/scripts/manual-checklist-macos.md new file mode 100644 index 0000000..9e23bbc --- /dev/null +++ b/local-tool/.autoflow/06-testing/scripts/manual-checklist-macos.md @@ -0,0 +1,264 @@ +# M9-5 macOS 實機驗證 checklist + +> 對應 plan:`../m9-5-validation-plan.md` +> 平台:macOS 14.x(Apple Silicon 或 Intel x86_64) +> 預估時間:3-4 小時(含 KL520 + KL720 各情境) +> 執行人:使用者(下週) + +--- + +## 0. 前置準備(10 分鐘) + +- [ ] 確認 macOS 版本:`sw_vers`、紀錄:__________ +- [ ] visionA-local A 階段 build(含 M9-1 ~ M9-4 commits)已安裝 +- [ ] 清空舊狀態:`rm -rf ~/Library/Application\ Support/visiona-local/` +- [ ] 準備硬體: + - [ ] KL520 KDP1 legacy dongle(若有、最重要):__________ + - [ ] KL520 KDP2 dongle:__________ + - [ ] KL720 dongle:__________ +- [ ] Chrome / Safari / Edge 任一最新版開啟、DevTools 準備好 +- [ ] Terminal 開好、`cd ~/Library/Application\ Support/visiona-local/` 看 log + +--- + +## 1. Functional 升級流程驗證(90 分鐘) + +### 1.1 情境 A:KL520 KDP1 → KDP2 完整 4-stage(30 分鐘) + +**前置**:插 KL520 KDP1 dongle + +- [ ] 啟動 visionA-local、Wails 視窗顯示 +- [ ] 控制台顯示 server URL(如 `http://127.0.0.1:3721`) +- [ ] 點「開啟瀏覽器」、Devices 頁出現 +- [ ] Devices 卡片顯示「KL520」+ **紅色 badge** `KDP1 (legacy)` +- [ ] hover badge 顯示 tooltip:「此韌體為舊版 KDP1...」 +- [ ] 卡片內顯示「升級到 v2.2.0」按鈕(primary、藍) +- [ ] 點升級按鈕、confirm modal 開啟 +- [ ] modal 標題:「升級韌體」 +- [ ] from/to 顯示:from = `KDP1`、to = `v2.2.0` +- [ ] 警告文字含「30 秒」「切勿拔除」 +- [ ] 點「開始升級」、modal 切到 upgrading phase +- [ ] **progress modal 不顯示 ✕ / 取消按鈕**(R-FW-11) +- [ ] 紅色 banner「⚠ 請勿拔除裝置」顯示 +- [ ] progress bar 從 0% 開始 +- [ ] stage 依序變化: + - [ ] 「階段 1 / 4:準備(偵測 + 連接裝置)」(preparing、~5%) + - [ ] 「階段 2 / 4:載入引導程式」(loading、~20%) + - [ ] 「階段 3 / 4:寫入韌體」(flashing、~50%) + - [ ] 「階段 4 / 4:驗證完成」(verifying、~90%) +- [ ] 「已耗時」秒數遞增 +- [ ] 「預估剩餘」秒數顯示(依 etaMs) +- [ ] **試 ESC 鍵 → modal 不關** +- [ ] **試點 modal 外部 → modal 不關** +- [ ] 升級在 60 秒內完成(**紀錄實測時長**:______ 秒) +- [ ] modal 顯示綠勾 ✓「完成」+ afterVersion `v2.2.0` +- [ ] 約 1.5 秒後 modal 自動關閉 +- [ ] 右上角 toast:「KL520 #1 升級成功 — 從 KDP1 升級到 v2.2.0(耗時 X 秒)」 +- [ ] toast 停留 6 秒、自動消失 +- [ ] Devices 卡片 FW badge **變綠** + `v2.2.0` +- [ ] tooltip 變:「韌體為最新版本(v2.2.0)」 +- [ ] Server log 沒有 panic / error(看 `~/Library/Application\ Support/visiona-local/server.log` 最後 100 行) + +**截圖**: +- [ ] 升級前 Devices 頁紅 badge:`.autoflow/06-testing/screenshots/macos-A-before.png` +- [ ] upgrading phase(任一 stage):`.autoflow/06-testing/screenshots/macos-A-upgrading.png` +- [ ] success toast:`.autoflow/06-testing/screenshots/macos-A-success.png` +- [ ] 升級後 Devices 頁綠 badge:`.autoflow/06-testing/screenshots/macos-A-after.png` + +### 1.2 情境 B:KL520 KDP2 short-circuit 3-stage(15 分鐘) + +**前置**:拔 KDP1 dongle、插 KL520 KDP2(v2.1.0 或 firmware 字串比 bundled current 舊)。或情境 A 跑完後重跑(badge 已綠則無 older 可驗、用「將 bundled VERSION 改為 v2.3.0」模擬 yellow 場景、若太複雜可跳過 yellow 驗證) + +- [ ] 卡片 badge 顯示 **黃色**(older)或 **綠色**(current、若沒法模擬就接受) +- [ ] 點升級 +- [ ] progress 序列:**3 stage**(不是 4): + - [ ] 階段 1 / 3:準備 + - [ ] 階段 2 / 3:寫入韌體(**沒有 loading 階段**) + - [ ] 階段 3 / 3:驗證完成 +- [ ] 升級在 60 秒內完成(**紀錄實測時長**:______ 秒) +- [ ] 成功 toast、badge 變綠 + +### 1.3 情境 C:KL720 升級(15 分鐘) + +**前置**:插 KL720 dongle + +- [ ] 卡片 badge 顯示綠或黃 +- [ ] 若為黃、點升級 +- [ ] confirm modal 預估時間:「3 分鐘」(180 秒) +- [ ] progress 序列:3 stage(KL720 預燒 KDP2、走 short-circuit) +- [ ] 升級在 200 秒內完成(**紀錄實測時長**:______ 秒) +- [ ] 成功 toast、badge 變綠 + +### 1.4 情境 D:來回升級(15 分鐘) + +**前置**:第二根 KL520 KDP1 dongle(若沒有則跳過) + +- [ ] 重複跑情境 A 一次 +- [ ] **驗證沒有 Error 15 SEND_DATA_TOO_LARGE**(KL520 reset bug 應已修) +- [ ] 連續兩次升級流程都正常 + +### 1.5 多裝置同時連接(15 分鐘) + +**前置**:KL520 + KL720 同時插上 + +- [ ] Devices 頁兩張卡片 +- [ ] 點 KL520 卡片升級、modal 開啟 +- [ ] 升級 upgrading phase 中、試圖點 KL720 卡片升級 +- [ ] **預期**:被擋(如 409 toast「裝置正在進行其他作業」)或 KL720 modal 開不起來 +- [ ] KL520 升級完成後、KL720 升級可正常進行 + +--- + +## 2. Reliability 失敗注入(45 分鐘) + +### 2.1 #1 scan_not_found(5 分鐘) + +- [ ] 拔 USB +- [ ] 立刻在 Devices 頁試圖點升級按鈕(如果卡片還在) +- [ ] 預期 error modal「找不到裝置」+「重新插拔後重試」按鈕 + +### 2.2 #3 loader_write_failed(10 分鐘) + +**注入**:升級進入 loading stage(紅 badge KDP1 → KDP2 流程)時、Terminal 跑 `pkill -9 python` 殺 bridge.py + +- [ ] error modal 出現 +- [ ] 顯示「引導程式載入失敗」 +- [ ] 顯示「拔插後重試」按鈕 +- [ ] errorCode 出現(如 `FW_LOADER_*`) +- [ ] 「複製錯誤訊息」按鈕可點、變「已複製 ✓」 +- [ ] **不顯示 brick warning role=note**(recoverable) + +### 2.3 #4 upgrade_mid_failed(10 分鐘) + +**注入**:升級進入 flashing stage 時、`pkill -9 python` + +- [ ] error modal 顯示「韌體寫入失敗、聯絡技術支援」 +- [ ] 顯示「重試」按鈕(**仍 recoverable**、不顯示 brick warning) +- [ ] errorCode:`FW_UPGRADE_*` +- [ ] 點「取得協助」(contact support、若有)開 mailto: handler + +### 2.4 #5 disconnect_during_op — **brick risk 最高**(10 分鐘) + +**注入**:升級進入 flashing stage 時、**拔 USB** + +- [ ] error modal 顯示「裝置已斷開」 +- [ ] **顯示 brick warning role=note**(如「裝置可能損壞」) +- [ ] **不顯示 Retry 按鈕**(destructive reason) +- [ ] 顯示 **「Contact Support」destructive 按鈕** +- [ ] 點 Contact Support、開 mailto: handler、subject + body 帶 errorCode + +### 2.5 #6 timeout(10 分鐘) + +**注入**:較難在實機重現、可: +- 拔 USB 但不重新插上、等 KL520 ≥ 60s + +- [ ] error modal 顯示「操作超時」 +- [ ] 顯示「拔插後重新掃描」按鈕 + +--- + +## 3. UX 驗證(30 分鐘) + +### 3.1 Badge 4 色(10 分鐘) + +| State | 觸發 | 紀錄 | +|-------|------|------| +| current(綠) | KDP2 firmware = bundled | [ ] 驗 | +| older(黃) | KDP2 firmware < bundled | [ ] 驗(若無法模擬可跳) | +| legacy(紅) | KDP1 dongle | [ ] 驗 | +| unknown(灰) | firmware 字串空 / loading | [ ] 驗(拔 USB 後重新 scan 過渡期) | + +### 3.2 進度 modal 警告 banner 全程顯示(5 分鐘) + +- [ ] 升級 preparing 時、banner 顯示 +- [ ] 升級 loading 時、banner 顯示 +- [ ] 升級 flashing 時、banner 顯示 +- [ ] 升級 verifying 時、banner 顯示 +- [ ] 升級 done 時、banner 不再顯示(modal 已切 success phase) + +### 3.3 Success toast 6 秒停留(5 分鐘) + +- [ ] 升級成功、toast 出現 +- [ ] **計時 6 秒後 toast 仍可見**(不是預設 4 秒) + +### 3.4 失敗 modal「複製錯誤訊息」(5 分鐘) + +- [ ] 點「複製錯誤訊息」按鈕 +- [ ] 按鈕變「已複製 ✓」 +- [ ] 約 2 秒後復原為「複製錯誤訊息」 +- [ ] Terminal 跑 `pbpaste`(macOS)確認剪貼簿內容含 `stage:` / `reason:` / `errorCode:` / `rawError:` + +### 3.5 「取得協助」mailto: 路徑(5 分鐘) + +- [ ] destructive reason 的 error modal 點 Contact Support 按鈕 +- [ ] macOS Mail.app 或預設 mail handler 開啟 +- [ ] subject 含 errorCode +- [ ] body 含技術資訊(stage / reason / deviceId / rawError) + +--- + +## 4. Integration 驗證(30 分鐘) + +### 4.1 SIGTERM 延遲關閉(15 分鐘) + +**前置**:終端機跑 `ps aux | grep visiona-local-server`、紀錄 server PID + +- [ ] 啟動升級流程(情境 A,慢一點的) +- [ ] 升級進入 flashing stage 時、Terminal 跑 `kill -TERM ` +- [ ] **server 不立即退出** +- [ ] 觀察 `~/Library/Application\ Support/visiona-local/server.log` 最後 50 行 +- [ ] log 含「firmware: N active firmware task(s) detected, delaying shutdown up to ...」 +- [ ] 升級正常完成(firmware task 完成) +- [ ] log 含「firmware: all firmware tasks completed, proceeding to shutdown」或「firmware: hard timeout ...」 +- [ ] server 完成升級後才退出 +- [ ] Wails 視窗收到 server offline 通知(offline overlay 顯示) + +### 4.2 Wails OnBeforeClose 攔截(15 分鐘) + +- [ ] 啟動升級流程 +- [ ] 升級進入 flashing stage 時、點 **Wails 視窗的紅色 ✕ 按鈕**(macOS title bar 左上) +- [ ] **Wails 視窗不關閉** +- [ ] Frontend modal 出現:「韌體切換進行中...」 +- [ ] modal 顯示 task info:device name / chip / direction / stage / etaSeconds +- [ ] modal 顯示「繼續等待」(primary)+「強制關閉」(destructive)按鈕 +- [ ] 點「強制關閉」、預期 **第二層 FORCE 確認**(要輸入「FORCE」字串) +- [ ] 試輸入「force」小寫 → 確認按鈕 disabled +- [ ] 試輸入空白 → 確認按鈕 disabled +- [ ] 輸入「FORCE」大寫 → 確認按鈕 enabled +- [ ] 點「繼續等待」(不點強制關閉)、modal 關閉、升級流程繼續 +- [ ] 升級正常完成 +- [ ] 升級完成後、再點 ✕ → Wails 視窗正常關閉 + +--- + +## 5. 驗證結果摘要 + +填寫: + +| 類別 | 結果 | 備註 | +|------|------|------| +| §1 Functional | ✅ Pass / ⚠ Partial / ❌ Fail | | +| §2 Reliability | | | +| §3 UX | | | +| §4 Integration | | | + +**P0 阻擋發布**:______(如「§1.1 升級流程崩潰」) +**P1 嚴重**:______ +**P2 一般**:______ +**P3 改善**:______ + +升級時長實測: + +| Combo | 實測 | 護欄 | Pass / Fail | +|-------|------|------|-----------| +| KL520 KDP1→KDP2 | __秒 | 60s | | +| KL520 short-circuit | __秒 | 60s | | +| KL720 | __秒 | 200s | | + +--- + +## 6. Bug 回報 + +對每個發現的問題、依 `../m9-5-validation-plan.md §11` 格式回報到 `.autoflow/06-testing/bugs/M9-5-BUG-{NN}.md`。 + +**完成簽核**:________________ 日期:________________ diff --git a/local-tool/.autoflow/06-testing/scripts/manual-checklist-windows.md b/local-tool/.autoflow/06-testing/scripts/manual-checklist-windows.md new file mode 100644 index 0000000..be00618 --- /dev/null +++ b/local-tool/.autoflow/06-testing/scripts/manual-checklist-windows.md @@ -0,0 +1,234 @@ +# M9-5 Windows 實機驗證 checklist + +> 對應 plan:`../m9-5-validation-plan.md` +> 平台:Windows 10 / 11(x86_64) +> 預估時間:3-4 小時 +> 執行人:使用者(下週) + +--- + +## 0. 前置準備(15 分鐘) + +- [ ] 確認 Windows 版本:`winver`、紀錄:__________ +- [ ] visionA-local A 階段 build 已安裝(含 M9-1 ~ M9-4) +- [ ] 清空舊狀態:刪除 `%APPDATA%\visiona-local\`(PowerShell:`Remove-Item -Recurse -Force "$env:APPDATA\visiona-local\"`) +- [ ] 準備硬體: + - [ ] KL520 KDP1 legacy dongle:__________ + - [ ] KL520 KDP2 dongle:__________ + - [ ] KL720 dongle:__________ +- [ ] **WinUSB driver 已綁定**(重要、KneronPLUS Windows 必要): + - [ ] 開「裝置管理員」→「通用序列匯流排控制器」 + - [ ] 確認看到 `Kneron USB Device` 或類似條目 + - [ ] 若沒有、跑 Zadig 工具綁定 WinUSB driver(installer 應已處理) +- [ ] Chrome / Edge 任一最新版開啟、F12 DevTools 準備好 +- [ ] Console / PowerShell 開好 + +--- + +## 1. Functional 升級流程驗證(90 分鐘) + +### 1.1 情境 A:KL520 KDP1 → KDP2 完整 4-stage(30 分鐘) + +**前置**:插 KL520 KDP1 dongle + +- [ ] 啟動 visionA-local(雙擊 `visiona-local.exe` 或 Start 選單) +- [ ] **Windows Defender 首次掃描**:可能延遲 30-60 秒(KneronPLUS dll 簽章)、若超過 60 秒持續啟動 → P1 bug +- [ ] Wails 視窗開啟、控制台顯示 server URL +- [ ] 點「開啟瀏覽器」、Devices 頁出現 +- [ ] **紅色 badge** `KDP1 (legacy)` 顯示 +- [ ] 點升級、confirm modal、from/to 正確 +- [ ] 點「開始升級」、upgrading phase +- [ ] **progress modal 不顯示 ✕ / 取消** +- [ ] 紅色 banner「請勿拔除裝置」顯示 +- [ ] stage 序列:preparing → loading → flashing → verifying +- [ ] ESC 不關 modal、點外部不關 +- [ ] 升級在 60 秒內完成(**實測時長**:______ 秒) +- [ ] modal 顯示綠勾、自動關閉、toast 顯示 6 秒 +- [ ] Devices 卡片 badge 變綠 + `v2.2.0` +- [ ] Server log(`%APPDATA%\visiona-local\server.log`)沒有 panic + +**截圖**: +- [ ] `.autoflow/06-testing/screenshots/windows-A-before.png` +- [ ] `.autoflow/06-testing/screenshots/windows-A-upgrading.png` +- [ ] `.autoflow/06-testing/screenshots/windows-A-success.png` + +### 1.2 情境 B:KL520 KDP2 short-circuit(15 分鐘) + +- [ ] 黃色 badge / 點升級 +- [ ] **3 stage 序列**(沒 loading) +- [ ] 60 秒內完成(**實測**:______ 秒) + +### 1.3 情境 C:KL720 升級(15 分鐘) + +- [ ] 點升級、confirm 顯示「3 分鐘」 +- [ ] 3 stage(KL720 走 short-circuit) +- [ ] 200 秒內完成(**實測**:______ 秒) + +### 1.4 情境 D:來回升級(15 分鐘) + +- [ ] 重跑情境 A 一次 +- [ ] **無 Error 15 SEND_DATA_TOO_LARGE** +- [ ] 兩次都成功 + +### 1.5 多裝置同時連接(15 分鐘) + +- [ ] KL520 + KL720 同時插 +- [ ] 兩張卡片 +- [ ] KL520 升級進行中、試 KL720 升級 +- [ ] 被擋(409 或 modal 不開) +- [ ] KL520 完成後 KL720 可升級 + +--- + +## 2. Reliability 失敗注入(45 分鐘) + +### 2.1 #1 scan_not_found(5 分鐘) + +- [ ] 拔 USB、點升級 +- [ ] error modal「找不到裝置」 + +### 2.2 #3 loader_write_failed(10 分鐘) + +**注入**:loading stage 時、Task Manager 強殺 `python.exe`(或 PowerShell 跑 `Stop-Process -Name python -Force`) + +- [ ] error modal「引導程式載入失敗」 +- [ ] 「拔插後重試」按鈕 +- [ ] **無 brick warning**(recoverable) + +### 2.3 #4 upgrade_mid_failed(10 分鐘) + +**注入**:flashing stage 時、強殺 python + +- [ ] error modal「韌體寫入失敗」 +- [ ] errorCode `FW_UPGRADE_*` +- [ ] 「重試」按鈕 + +### 2.4 #5 disconnect_during_op(10 分鐘) + +**注入**:flashing stage 時、**拔 USB** + +- [ ] error modal「裝置已斷開」 +- [ ] **brick warning role=note** +- [ ] **無 Retry 按鈕** +- [ ] **Contact Support destructive 按鈕** +- [ ] 點 Contact Support、開 Outlook / 預設 mail handler + +### 2.5 #6 timeout(10 分鐘) + +- [ ] 拔 USB 後等 KL520 > 60s +- [ ] error modal「操作超時」 + +--- + +## 3. UX 驗證(30 分鐘) + +### 3.1 Badge 4 色 + +| State | 觸發 | 紀錄 | +|-------|------|------| +| current(綠) | | [ ] | +| older(黃) | | [ ] | +| legacy(紅) | KDP1 | [ ] | +| unknown(灰) | scan 過渡期 | [ ] | + +### 3.2 Modal 警告 banner 全程顯示 + +- [ ] 4 個 stage 都顯示「請勿拔除裝置」 + +### 3.3 Toast 停留 6 秒 + +- [ ] 計時驗證 + +### 3.4 複製錯誤訊息 + +- [ ] 點複製、變「已複製 ✓」 +- [ ] PowerShell `Get-Clipboard` 確認內容 + +### 3.5 mailto: 開啟 + +- [ ] destructive reason 點 Contact Support +- [ ] Outlook / Windows Mail / 預設 mail handler 開啟 + +--- + +## 4. Integration 驗證(30 分鐘) + +### 4.1 SIGTERM 延遲關閉 + +⚠ **Windows SIGTERM 行為不直觀**——`taskkill /F` 等於 SIGKILL、不會走 graceful。 + +**替代方法**: +- 開兩個 visionA-local 實例(second 應該失敗、single-instance lock)→ 觀察 first instance behavior +- 或:跑升級、然後 Task Manager「End Task」(不 force)視為 SIGTERM 等效 + +- [ ] 啟動升級流程 +- [ ] flashing stage 時、Task Manager → End Task(不 Force) +- [ ] visionA-local server 應延遲關閉、log 顯示「delaying shutdown」 +- [ ] 升級完成才真正退出 + +### 4.2 Wails OnBeforeClose 攔截 + +- [ ] 啟動升級流程 +- [ ] flashing stage 時、點 Wails 視窗右上 **✕** +- [ ] **視窗不關** +- [ ] Frontend modal 出現「韌體切換進行中...」 +- [ ] modal 顯示 task info(device name / chip / stage / etaSeconds) +- [ ] 「繼續等待」+「強制關閉」按鈕 +- [ ] 點強制關閉、第二層 FORCE 確認 +- [ ] 輸入「force」小寫 → disabled +- [ ] 輸入「FORCE」 → enabled +- [ ] 點「繼續等待」、modal 關、升級繼續 +- [ ] 升級完成、再點 ✕ → 視窗正常關 + +### 4.3 Alt+F4 攔截 + +⚠ Windows 特有:使用者可能用 Alt+F4 關視窗 + +- [ ] 升級進行中、Alt+F4 +- [ ] **預期**:同 ✕ 按鈕、攔截 modal 顯示 +- [ ] 若 Alt+F4 直接關 → P1 bug + +--- + +## 5. Windows 特有觀察點 + +### 5.1 USB driver 重新枚舉 + +升級成功後 device re-enumerate、Windows USB stack 可能不穩: + +- [ ] 升級成功後 5-8 秒內、Devices 頁卡片自動更新(依 needsReset + rescan) +- [ ] 若超過 30 秒仍未更新 → P1 bug(既有 KL520 reset bug 重現) + +### 5.2 Defender 即時掃描 + +- [ ] 啟動時間是否 > 60 秒(首次)/ > 10 秒(後續) +- [ ] 升級期間 Defender 是否擾動 USB I/O(罕見但可能) + +--- + +## 6. 驗證結果摘要 + +| 類別 | 結果 | 備註 | +|------|------|------| +| §1 Functional | | | +| §2 Reliability | | | +| §3 UX | | | +| §4 Integration | | | +| §5 Windows 特有 | | | + +**升級時長實測**: + +| Combo | 實測 | 護欄 | Pass / Fail | +|-------|------|------|-----------| +| KL520 KDP1→KDP2 | __秒 | 60s | | +| KL520 short-circuit | __秒 | 60s | | +| KL720 | __秒 | 200s | | + +--- + +## 7. Bug 回報 + +依 `../m9-5-validation-plan.md §11` 格式回報到 `.autoflow/06-testing/bugs/M9-5-BUG-{NN}.md`。 +標題加「[Windows]」前綴方便分流。 + +**完成簽核**:________________ 日期:________________ diff --git a/local-tool/.autoflow/06-testing/scripts/wails-onbeforeclose-firmware-active.spec.ts b/local-tool/.autoflow/06-testing/scripts/wails-onbeforeclose-firmware-active.spec.ts new file mode 100644 index 0000000..d8fe1bf --- /dev/null +++ b/local-tool/.autoflow/06-testing/scripts/wails-onbeforeclose-firmware-active.spec.ts @@ -0,0 +1,217 @@ +/** + * 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 { + 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 void)[]>; +} { + const subscribers = new Map 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 自動化腳本對照表) + }); +}); diff --git a/local-tool/.autoflow/progress.md b/local-tool/.autoflow/progress.md index 720d50f..dfc6378 100644 --- a/local-tool/.autoflow/progress.md +++ b/local-tool/.autoflow/progress.md @@ -368,7 +368,19 @@ - 2 nice-to-have Suggestion(mailto 地址抽常數 / handleCopy 補 test)— 不阻擋 - ContactSupport mailto 安全性驗證通過(RFC 6068 + encodeURIComponent) - [x] **M9-4 整體完成**(2026-05-25)→ 通過、可進 M9-5 -- [ ] M9-5 三平台實機驗證 +- [x] **M9-5 Testing plan + e2e scripts 完成**(2026-05-25) + - `.autoflow/06-testing/m9-5-validation-plan.md`(656 行、4 情境 × 3 平台 × 2 chip = 24 combo) + - **4 e2e scripts**(vitest + RTL + mock WS / mock fetch、不引入 Playwright): + - 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 checklist**(給你下週手動跑):macOS 264 / Windows 234 / Linux 243 行 + - **MJ3 順手修**:firmware_ws_test.go +16/-8(`phase`→`stage` + `firmware:progress`→`firmware_progress` 對齊 TDD §4.2) + - go test ./... -race 全綠(server / wails / frontend 60 tests) + - **執行建議**:第 1 天 P0(macOS+Win+Linux × KL520+KL720 happy path、~3h)/ 第 2 天 P1(R-FW-11 + 失敗注入、4h)/ 第 3 天 P2(SIGTERM + Wails OnBeforeClose、2-3h) + - **使用者下週執行**、跑完依結果決定 A 階段是否交付(pass → reviewer 收尾 → 進 B 階段 / fail → 派對應 sub-agent 修) +- [ ] M9-5 使用者下週實機執行(macOS/Windows/Linux 三平台 × KL520+KL720) - [ ] M9-6 ~ M9-13(B 階段擴展) --- diff --git a/local-tool/server/internal/api/ws/firmware_ws_test.go b/local-tool/server/internal/api/ws/firmware_ws_test.go index 71d8c44..19b636d 100644 --- a/local-tool/server/internal/api/ws/firmware_ws_test.go +++ b/local-tool/server/internal/api/ws/firmware_ws_test.go @@ -8,6 +8,12 @@ package ws // // 目的:保證 /ws/devices/:id/firmware-progress endpoint 把 client 正確 // join 到 "firmware:" room、且 broadcast 能送達。 +// +// MJ3(M9-5 Testing 修):smoke test 內 schema 對齊 TDD §4.2 + M9-3 +// broadcast schema:欄位用 `stage`(不是 `phase`)、type 用 +// `firmware_progress`(不是 `firmware:progress`、那是 room 名)。 +// firmware_handler.go forwardProgressToWS 廣播的就是 `type: firmware_progress` +// + `stage: preparing|loading|flashing|verifying|done|error`。 import ( "encoding/json" @@ -58,11 +64,12 @@ func TestFirmwareProgressHandler_ReceivesBroadcast(t *testing.T) { time.Sleep(10 * time.Millisecond) } - // 廣播 firmware progress 事件 + // 廣播 firmware progress 事件(schema 對齊 TDD §4.2 + handler 端 + // firmwareProgressMessage struct)。 hub.BroadcastToRoom(room, map[string]interface{}{ - "type": "firmware:progress", + "type": "firmware_progress", "deviceId": deviceID, - "phase": "flashing", + "stage": "flashing", "percent": 42, }) @@ -75,14 +82,14 @@ func TestFirmwareProgressHandler_ReceivesBroadcast(t *testing.T) { if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("json: %v; raw=%s", err, string(data)) } - if got["type"] != "firmware:progress" { + if got["type"] != "firmware_progress" { t.Errorf("wrong type: %+v", got) } if got["deviceId"] != deviceID { t.Errorf("wrong deviceId: %+v", got) } - if got["phase"] != "flashing" { - t.Errorf("wrong phase: %+v", got) + if got["stage"] != "flashing" { + t.Errorf("wrong stage: %+v", got) } } @@ -135,10 +142,11 @@ func TestFirmwareProgressHandler_RoomIsolation(t *testing.T) { time.Sleep(10 * time.Millisecond) } - // 只 broadcast 到 kl520-0 的 room + // 只 broadcast 到 kl520-0 的 room(schema 對齊 TDD §4.2) hub.BroadcastToRoom("firmware:kl520-0", map[string]interface{}{ - "type": "firmware:progress", + "type": "firmware_progress", "deviceId": "kl520-0", + "stage": "preparing", "percent": 10, })