test(local-tool): M9-5 — three-platform validation plan + e2e scripts + MJ3 fix

A 階段最後 milestone、出測試計畫 + 自動化腳本 + 三平台人工 checklist、使用者下週手動跑實機驗證。

Testing artifacts (8 檔、2630 行):
- .autoflow/06-testing/m9-5-validation-plan.md: 656 行(4 情境 × 3 平台 × 2 chip = 24 combo)
- 4 e2e specs (vitest + RTL + mock WS / mock fetch):
  - firmware-upgrade-happy-path.spec.ts (357 / 4 cases)
  - firmware-upgrade-error-recovery.spec.ts (356 / 4 cases + 8 reason it.each)
  - firmware-r-fw-11-modal-not-closable.spec.ts (303 / 6 cases)
  - wails-onbeforeclose-firmware-active.spec.ts (217 / 9 cases、含 5 todo 占位 M9-12)
- 3 manual checklists: macOS 264 / Windows 234 / Linux 243 行

設計取捨:
- 不引入 Playwright/Cypress (visionA-local frontend 沒裝、屬 architect 決策)、走 vitest + mock
- E2E 腳本放 06-testing/scripts/ 作 spec doc + 可選實作參考
- 實機驗證走人工 checklist (dongle 插拔 / kill process / SIGTERM 等需要實體互動)

MJ3 修復 (M9-4 reviewer round 1 留的 follow-up):
- server/internal/api/ws/firmware_ws_test.go: +16/-8
- "type": "firmware:progress" → "firmware_progress" (對齊 firmwareProgressMessage.Type)
- "phase" → "stage" (對齊 TDD §4.2 + FirmwareProgress.Stage)
- 不動 production code、只 test schema 對齊

執行建議 (給你下週):
- Day 1 P0: macOS+Win+Linux × KL520+KL720 happy path (~3h)
- Day 2 P1: R-FW-11 + disconnect_during_op + upgrade_mid_failed + 失敗注入 (4h)
- Day 3 P2: SIGTERM 延遲關閉 + Wails OnBeforeClose force-quit modal (2-3h)

測試:
- go test ./... -race 全綠 (server / wails / frontend 60 tests)
- MJ3 修復不破壞既有測試

A 階段開發 6/7 完成 (M9 文件 + M9-1 ~ M9-4.5)、剩 M9-5 實機驗證 (你下週跑)、跑完依結果決定 A 階段交付或派 sub-agent 修。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-25 15:34:17 +08:00
parent ff9bbc81ed
commit 8c27da7cca
10 changed files with 2659 additions and 9 deletions

View File

@ -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.02026-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 testUR-1~3、post-launch
### 1.2 驗證目標pass criteria
| 類別 | 目標 |
|------|------|
| Functional | 4 情境 × 6 combo3 平台 × 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-circuit3 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 comboLinux 補上 = 12
**Integration 額外實驗**
- 21-24每平台跑「升級期間關 Wails 視窗→force-quit modal」「升級期間 SIGTERM→延遲關閉」2 種、合計 6 combo
合計 ~24 個 combo使用者可依時間裁切§12 給優先順序)。
---
## 4. 功能性驗證Functional
### 4.1 4 個升級情境(每平台 × 每 chip 都跑)
#### 情境 AKDP1 → KDP2 完整升級4 stage、AC-FW-1.2
**前置條件**:插一根 KL520 KDP1 legacy donglefirmware 字串 `KDP Comp/U` 或類似)。
**測試步驟**
1. 確認 visionA-local 已啟動、瀏覽器開 Devices 頁
2. 確認偵測到 dongle、卡片右上角 FW badge **紅色** + 文字「KDP1 (legacy)」+ tooltip「此韌體為舊版 KDP1...」
3. 點卡片內「升級到最新」按鈕labels`升級到 v2.2.0`
4. 確認 confirm modal 顯示:
- 標題「升級韌體」
- from/tofrom = `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
#### 情境 BKDP2 → KDP2 short-circuit3 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 內完成
#### 情境 CKL720 升級
**前置條件**KL720 donglefirmware 為 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 reasondisconnect/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 | ~30swarrenchen 實測) | 60s | timeout、UI 顯示 `Reason="timeout"` |
| KL720 | ~180swarrenchen 實測) | 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 沒有 eventUI 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 = trueKDP1| 紅 | `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 復原 UIDesign §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: handlersubject + 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 <PID>`
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 modalTDD §8.6.2 + visiona-local/firmware_close_guard.go
**測試步驟**
1. 任一平台 + 任一 chip
2. 啟動升級流程(情境 A 或 C
3. 升級到 flashing stage 時、點 Wails 視窗的 ✕ 關閉按鈕macOS紅色關閉、Windows右上 ✕、Linuxwindow 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 infodevice 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 <server-PID>`(強制殺、不走 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 componentbadge / error view | 已有 vitest 全綠 | `frontend/src/tests/components/firmware-*.test.tsx` |
| **E2E happy pathmock 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.5KL520 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.
## 對應 ACAC-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 + UX4 小時)
對 macOS或第 1 天最穩的平台)跑:
1. R-FW-11 modal 不可關5 分鐘)
2. Badge 4 色顯示10 分鐘)
3. 失敗注入 #1 scan_not_found5 分鐘)
4. 失敗注入 #3 loader_write_failed10 分鐘)
5. 失敗注入 #4 upgrade_mid_failed10 分鐘)
6. 失敗注入 #5 disconnect_during_op10 分鐘)
7. 失敗注入 #6 timeout10 分鐘)
8. UX checklisttoast / 復原 UI / mailto: 開啟30 分鐘)
再對 Windows + Linux 跑 #5 disconnect_during_op + #4 upgrade_mid_failed兩個 brick risk 最高的)作為 sanity check。
### 12.3 第 3 天integration2-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:<port>/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` — 查詢進行中 taskWails 用)
### 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 |

View File

@ -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 OnBeforeClosewails-onbeforeclose-firmware-active.spec.ts
* - modal error phasemodal R-FW-11
*
* Owner: Testing Agent (M9-5)
* Last reviewed: 2026-05-25
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { FirmwareUpgradeDialog } from '@/components/firmware/firmware-upgrade-dialog';
import { useFirmwareStore } from '@/stores/firmware-store';
import type { Device, FirmwareProgressEvent } from '@/types/device';
function makeLegacyKL520(): Device {
return {
id: 'kl520-0',
name: 'KL520 #1',
type: 'kneron_kl520',
port: '/dev/usb0',
status: 'connected',
firmwareVersion: 'KDP1',
firmwareIsLegacy: true,
firmwareCanUpgrade: true,
bundledFirmwareVersion: 'v2.2.0',
};
}
class MockWebSocket {
static instances: MockWebSocket[] = [];
static OPEN = 1;
readyState = 0;
onopen: ((e: Event) => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
onclose: ((e: CloseEvent) => void) | null = null;
onerror: ((e: Event) => void) | null = null;
constructor(public url: string) {
MockWebSocket.instances.push(this);
queueMicrotask(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event('open'));
});
}
send() {}
close() {
this.readyState = 3;
this.onclose?.(new CloseEvent('close'));
}
pushMessage(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
}
}
function installMockWebSocket() {
MockWebSocket.instances = [];
(globalThis as any).WebSocket = MockWebSocket;
}
function installMockFetch() {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, data: { taskId: 'task-001' } }),
});
}
vi.mock('@/stores/device-store', () => ({
useDeviceStore: Object.assign(
(selector: (s: any) => any) => selector({ fetchDevices: vi.fn() }),
{ getState: () => ({ fetchDevices: vi.fn() }) },
),
}));
async function getToUpgradingPhase() {
const onOpenChange = vi.fn();
const device = makeLegacyKL520();
const { container } = render(
<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />,
);
const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ });
await act(async () => {
fireEvent.click(startBtn);
await Promise.resolve();
await Promise.resolve();
});
// 推一個 preparing event 確保進 upgrading
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
act(() => {
ws.pushMessage({
type: 'firmware_progress',
deviceId: 'kl520-0',
stage: 'preparing',
percent: 5,
elapsedMs: 500,
} as FirmwareProgressEvent);
});
await waitFor(() => {
expect(useFirmwareStore.getState().phase).toBe('upgrading');
});
return { container, onOpenChange, ws };
}
describe('M9-5 E2E #3: R-FW-11 modal 不可關upgrading phase', () => {
beforeEach(() => {
installMockWebSocket();
installMockFetch();
useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null });
});
it('upgrading phase: showCloseButton={false} 確保 ✕ 按鈕不渲染', async () => {
const { container } = await getToUpgradingPhase();
// shadcn Dialog 的 close button 通常是 button + sr-only "Close" text
// upgrading phase 不該有
const closeButtons = container.querySelectorAll('button[aria-label*="lose"], button[aria-label*="關閉"]');
// 注意modal 內部可能有「關閉」context、這裡只擋 Dialog 內建的 ✕
// FirmwareUpgradeDialog 在 isInProgress=true 時 showCloseButton={false}
// 因此 DialogContent 的內建 close 按鈕應該不存在
// shadcn 預設用 X icon button、aria-label 通常是 "Close"
expect(closeButtons.length).toBe(0);
});
it('upgrading phase: ESC 鍵不關 modalonOpenChange(false) 被攔截)', async () => {
const { container, onOpenChange } = await getToUpgradingPhase();
// 模擬 ESC 鍵
const dialog = container.querySelector('[role="dialog"]') || document.body;
fireEvent.keyDown(dialog, { key: 'Escape', code: 'Escape' });
// store phase 仍為 upgrading
expect(useFirmwareStore.getState().phase).toBe('upgrading');
// onOpenChange(false) 不該被叫dialog wrapper 攔截)
const closeCalls = onOpenChange.mock.calls.filter((c) => c[0] === false);
expect(closeCalls.length).toBe(0);
});
it('upgrading phase: 程式呼 onOpenChange(false) 被 dialog wrapper 擋住', async () => {
// 這是 firmware-upgrade-dialog 內 onOpenChange wrapper 的邏輯:
// if (!next && isInProgress) return;
// 換句話說、雖然 dialog 本身可以 open=false但本元件包了一層攔截。
//
// 此測試走「子元件透過 onOpenChange(false) 試圖關閉」路徑、驗 wrapper 擋住。
const onOpenChange = vi.fn();
const device = makeLegacyKL520();
// 設 store 直接進 upgrading不走 confirm
useFirmwareStore.setState({
activeDeviceId: 'kl520-0',
phase: 'upgrading',
progress: {
type: 'firmware_progress',
deviceId: 'kl520-0',
stage: 'flashing',
percent: 50,
elapsedMs: 10000,
} as FirmwareProgressEvent,
beforeVersion: 'KDP1',
targetVersion: 'v2.2.0',
startedAt: Date.now(),
});
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
// 直接看 store phase: 確認仍是 upgrading
expect(useFirmwareStore.getState().phase).toBe('upgrading');
// FirmwareUpgradeDialog 內 onOpenChange wrapper
// onOpenChange={(next) => { if (!next && isInProgress) return; ... }}
// → next=false 且 isInProgress=true 時、不會呼 onOpenChange(false) 給父元件
// 我們無法直接觸發 Radix 內部的 close 機制jsdom 限制)、但走 ESC / outside click
// 已被 onInteractOutside / onEscapeKeyDown 上 e.preventDefault() 攔截
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});
it('confirming phase: ✕ / ESC 可正常關閉(對比 upgrading', async () => {
const onOpenChange = vi.fn();
const device = makeLegacyKL520();
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
// confirm phase
expect(useFirmwareStore.getState().phase).toBe('confirming');
// 點「取消」按鈕應該關 modal
const cancelBtn = screen.getByRole('button', { name: /取消|Cancel/ });
fireEvent.click(cancelBtn);
// confirm phase 點 cancel → store.cancelConfirm → phase: idle
expect(useFirmwareStore.getState().phase).toBe('idle');
});
it('error phase: modal 可關(已脫離 critical zone、R-FW-11 不適用)', async () => {
installMockWebSocket();
installMockFetch();
const onOpenChange = vi.fn();
const device = makeLegacyKL520();
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ });
await act(async () => {
fireEvent.click(startBtn);
await Promise.resolve();
await Promise.resolve();
});
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
// 推 error 事件
act(() => {
ws.pushMessage({
type: 'firmware_progress',
deviceId: 'kl520-0',
stage: 'error',
percent: -1,
elapsedMs: 5000,
reason: 'connect_failed',
error: 'connection refused',
});
});
await waitFor(() => {
expect(useFirmwareStore.getState().phase).toBe('error');
});
// error phase 點「Close / 關閉」可關
const closeBtn = screen.getByRole('button', { name: /Close|關閉/ });
fireEvent.click(closeBtn);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe('M9-5 E2E #3b: 升級進度警告 banner 全程顯示', () => {
beforeEach(() => {
installMockWebSocket();
installMockFetch();
useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null });
});
it('preparing / loading / flashing / verifying 4 階段都顯示「請勿拔除裝置」banner', async () => {
await getToUpgradingPhase();
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
for (const stage of ['preparing', 'loading', 'flashing', 'verifying'] as const) {
act(() => {
ws.pushMessage({
type: 'firmware_progress',
deviceId: 'kl520-0',
stage,
percent: 50,
elapsedMs: 5000,
} as FirmwareProgressEvent);
});
await waitFor(() => {
expect(useFirmwareStore.getState().progress?.stage).toBe(stage);
});
// 紅色 banner role=alert 顯示
// firmware-progress-view.tsx 內:
// {progress.stage !== 'done' && (<div role="alert">⚠ 請勿拔除裝置</div>)}
expect(screen.getByRole('alert').textContent).toMatch(/拔除|unplug/i);
}
// done 時 banner 不該顯示
act(() => {
ws.pushMessage({
type: 'firmware_progress',
deviceId: 'kl520-0',
stage: 'done',
percent: 100,
elapsedMs: 28000,
afterVersion: 'v2.2.0',
});
});
await waitFor(() => {
expect(useFirmwareStore.getState().phase).toBe('success');
});
// success phase 進入後、progress-view 已不渲染
// 直接驗 phase 即可
});
});

View File

@ -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> = {}): 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(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ });
await act(async () => {
fireEvent.click(startBtn);
await Promise.resolve();
await Promise.resolve();
});
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 Supportdestructive',
},
{
reason: 'verify_mismatch',
stage: 'verifying',
isDestructive: true,
expectedButtonPattern: /Contact|Support|聯絡|技術支援/,
description: 'verify_mismatch → Contact Supportdestructive',
},
{
reason: 'verify_not_found',
stage: 'verifying',
isDestructive: true,
expectedButtonPattern: /Contact|Support|聯絡|技術支援/,
description: 'verify_not_found → Contact Supportdestructive',
},
];
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<typeof vi.fn>).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');
});
});

View File

@ -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 handlepreparing/loading/flashing/verifying/done
* - 4-stage isLegacyUpgrade=truevs 3-stagefalse
*
* spec
* - R-FW-11 modal firmware-r-fw-11-modal-not-closable.spec.ts
* - firmware-upgrade-error-recovery.spec.ts
* - Wails OnBeforeClosewails-onbeforeclose-firmware-active.spec.ts
*
*
* - .autoflow/06-testing/scripts/ spec doc
* - frontend/src/tests/e2e/ pnpm test vitest
* - 使 spec step checklist
*
*
* - Playwright/CypressvisionA-local frontend framework
* architect testing
* - vitest + RTL + jsdom + mock global WebSocket + mock fetch
* - component build stub firmware-upgrade-dialog lifecycle
*
* Owner: Testing Agent (M9-5)
* Last reviewed: 2026-05-25
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { FirmwareUpgradeDialog } from '@/components/firmware/firmware-upgrade-dialog';
import { useFirmwareStore } from '@/stores/firmware-store';
import type { Device, FirmwareProgressEvent } from '@/types/device';
// ──────────────────────────────────────────────────────────────────────
// Test fixtures (Factory pattern)
// ──────────────────────────────────────────────────────────────────────
function makeLegacyKL520(overrides: Partial<Device> = {}): Device {
return {
id: 'kl520-0',
name: 'KL520 #1',
type: 'kneron_kl520',
port: '/dev/usb0',
status: 'connected',
firmwareVersion: 'KDP1',
firmwareIsLegacy: true,
firmwareCanUpgrade: true,
bundledFirmwareVersion: 'v2.2.0',
...overrides,
};
}
function makeKDP2KL520(overrides: Partial<Device> = {}): Device {
return {
id: 'kl520-0',
name: 'KL520 #1',
type: 'kneron_kl520',
port: '/dev/usb0',
status: 'connected',
firmwareVersion: 'v2.1.0',
firmwareIsLegacy: false,
firmwareCanUpgrade: true,
bundledFirmwareVersion: 'v2.2.0',
...overrides,
};
}
function makeProgressEvent(
overrides: Partial<FirmwareProgressEvent> = {},
): FirmwareProgressEvent {
return {
type: 'firmware_progress',
deviceId: 'kl520-0',
stage: 'preparing',
percent: 5,
elapsedMs: 1000,
direction: 'upgrade',
...overrides,
};
}
// ──────────────────────────────────────────────────────────────────────
// Mock WS + fetch helpers
// ──────────────────────────────────────────────────────────────────────
/** MockWebSocket — 模擬 global WebSocket 給 createWebSocket() 用 */
class MockWebSocket {
static instances: MockWebSocket[] = [];
static OPEN = 1;
static CLOSED = 3;
readyState: number = 0;
onopen: ((e: Event) => void) | null = null;
onmessage: ((e: MessageEvent) => void) | null = null;
onclose: ((e: CloseEvent) => void) | null = null;
onerror: ((e: Event) => void) | null = null;
sentMessages: string[] = [];
constructor(public url: string) {
MockWebSocket.instances.push(this);
// 模擬立即 open
queueMicrotask(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event('open'));
});
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
}
/** Test helper推一個 message event 給訂閱者 */
pushMessage(payload: unknown) {
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
}
}
function installMockWebSocket() {
MockWebSocket.instances = [];
(globalThis as any).WebSocket = MockWebSocket;
}
function getActiveMockWS(): MockWebSocket {
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
if (!ws) throw new Error('no mock WebSocket instance');
return ws;
}
function installMockFetch() {
// POST /api/devices/kl520-0/firmware/upgrade → 202 {success:true, data:{taskId}}
global.fetch = vi.fn().mockImplementation(async (url: string, opts?: RequestInit) => {
if (url.includes('/devices/') && url.includes('/firmware/upgrade')) {
return {
ok: true,
json: async () => ({
success: true,
data: { taskId: 'task-upgrade-kl520-0-001' },
}),
};
}
// 預設404
return { ok: false, json: async () => ({ success: false, error: { code: 'NOT_FOUND', message: 'unknown path' } }) };
});
}
// ──────────────────────────────────────────────────────────────────────
// Mock device-store fetchDevices (called by dialog 在 success effect 內)
// ──────────────────────────────────────────────────────────────────────
vi.mock('@/stores/device-store', () => ({
useDeviceStore: Object.assign(
(selector: (s: any) => any) => selector({ fetchDevices: vi.fn() }),
{
getState: () => ({ fetchDevices: vi.fn() }),
},
),
}));
// ──────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────
describe('M9-5 E2E #1: KDP1 → KDP2 4-stage happy path', () => {
beforeEach(() => {
installMockWebSocket();
installMockFetch();
useFirmwareStore.setState({
activeDeviceId: null,
activeTaskId: null,
phase: 'idle',
progress: null,
beforeVersion: null,
targetVersion: null,
startedAt: null,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('AC-FW-1.2: confirm → upgrading 4-stage 序列正確preparing→loading→flashing→verifying→done', async () => {
const device = makeLegacyKL520();
const onOpenChange = vi.fn();
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
// Phase 1: confirm modal
expect(useFirmwareStore.getState().phase).toBe('confirming');
expect(screen.getByText(/升級韌體|Upgrade firmware/)).toBeTruthy();
// Phase 2: 點「開始升級」
const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ });
await act(async () => {
fireEvent.click(startBtn);
// 等 WS connect (microtask) + fetch resolve
await Promise.resolve();
await Promise.resolve();
});
await waitFor(() => {
expect(useFirmwareStore.getState().phase).toBe('upgrading');
});
// Phase 3: backend 推 4 個 stage events
const ws = getActiveMockWS();
const stages: Array<{ stage: FirmwareProgressEvent['stage']; percent: number }> = [
{ stage: 'preparing', percent: 5 },
{ stage: 'loading', percent: 20 },
{ stage: 'flashing', percent: 50 },
{ stage: 'verifying', percent: 90 },
];
for (const { stage, percent } of stages) {
act(() => {
ws.pushMessage(makeProgressEvent({ stage, percent, elapsedMs: percent * 100 }));
});
await waitFor(() => {
expect(useFirmwareStore.getState().progress?.stage).toBe(stage);
});
// store phase 維持 upgrading
expect(useFirmwareStore.getState().phase).toBe('upgrading');
}
// Phase 4: 推 done
act(() => {
ws.pushMessage(
makeProgressEvent({
stage: 'done',
percent: 100,
elapsedMs: 28000,
afterVersion: 'v2.2.0',
}),
);
});
await waitFor(() => {
expect(useFirmwareStore.getState().phase).toBe('success');
});
expect(useFirmwareStore.getState().progress?.afterVersion).toBe('v2.2.0');
});
it('AC-FW-1.3: 升級成功後 1.5 秒 modal 自動關閉 + onOpenChange(false) 被叫', async () => {
vi.useFakeTimers();
const device = makeLegacyKL520();
const onOpenChange = vi.fn();
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={onOpenChange} />);
const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ });
await act(async () => {
fireEvent.click(startBtn);
await Promise.resolve();
await Promise.resolve();
});
const ws = getActiveMockWS();
act(() => {
ws.pushMessage(
makeProgressEvent({ stage: 'done', percent: 100, elapsedMs: 28000, afterVersion: 'v2.2.0' }),
);
});
// 1.5 秒 timer
await act(async () => {
vi.advanceTimersByTime(1500);
});
expect(onOpenChange).toHaveBeenCalledWith(false);
vi.useRealTimers();
});
});
describe('M9-5 E2E #1b: KDP2 short-circuit 3-stage path', () => {
beforeEach(() => {
installMockWebSocket();
installMockFetch();
useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null });
});
it('isLegacyUpgrade=false 時、stageOrdinal 算出 3-stage 路徑preparing→flashing→verifying', async () => {
const device = makeKDP2KL520(); // firmwareIsLegacy=false
render(<FirmwareUpgradeDialog device={device} open={true} onOpenChange={() => {}} />);
const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ });
await act(async () => {
fireEvent.click(startBtn);
await Promise.resolve();
await Promise.resolve();
});
const ws = getActiveMockWS();
// KDP2 short-circuit 不走 loading stage
// 推 preparing → flashing → verifying → done
for (const stage of ['preparing', 'flashing', 'verifying', 'done'] as const) {
act(() => {
ws.pushMessage(makeProgressEvent({ stage, percent: stage === 'done' ? 100 : 50 }));
});
await waitFor(() => {
expect(useFirmwareStore.getState().progress?.stage).toBe(stage);
});
}
expect(useFirmwareStore.getState().phase).toBe('success');
});
});
describe('M9-5 E2E #1c: Per-device isolation多裝置同時不互相干擾', () => {
beforeEach(() => {
installMockWebSocket();
installMockFetch();
useFirmwareStore.setState({ phase: 'idle', progress: null, activeDeviceId: null });
});
it('activeDeviceId=A 時、device B 的 progress event 不影響 A 的 store', async () => {
const deviceA = makeLegacyKL520({ id: 'kl520-A', name: 'KL520 #A' });
render(<FirmwareUpgradeDialog device={deviceA} open={true} onOpenChange={() => {}} />);
const startBtn = screen.getByRole('button', { name: /開始升級|Start upgrade/ });
await act(async () => {
fireEvent.click(startBtn);
await Promise.resolve();
await Promise.resolve();
});
// 直接呼 store.handleEvent 模擬 device B 的事件混進來
act(() => {
useFirmwareStore.getState().handleEvent({
type: 'firmware_progress',
deviceId: 'kl720-B', // ← 不是 active device
stage: 'error',
percent: -1,
elapsedMs: 1000,
reason: 'connect_failed',
});
});
// store 不該被 device B 的 error event 影響
expect(useFirmwareStore.getState().phase).toBe('upgrading');
expect(useFirmwareStore.getState().activeDeviceId).toBe('kl520-A');
});
});

View File

@ -0,0 +1,243 @@
# M9-5 Linux 實機驗證 checklist
> 對應 plan`../m9-5-validation-plan.md`
> 平台Ubuntu 20.04+ / Debian 11+ / 同等 distrox86_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 情境 AKL520 KDP1 → KDP2 完整 4-stage30 分鐘)
**前置**:插 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 情境 BKL520 KDP2 short-circuit15 分鐘)
- [ ] 黃色 badge / 點升級
- [ ] **3 stage 序列**
- [ ] 60 秒內完成(**實測**______ 秒)
### 1.3 情境 CKL720 升級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_found5 分鐘)
- [ ] 拔 USB、點升級
- [ ] error modal「找不到裝置」
### 2.2 #3 loader_write_failed10 分鐘)
**注入**loading stage 時、`pkill -9 python3``pkill -9 -f kneron_bridge.py`
- [ ] error modal「引導程式載入失敗」
- [ ] 「拔插後重試」按鈕
- [ ] **無 brick warning**
### 2.3 #4 upgrade_mid_failed10 分鐘)
**注入**flashing stage 時、`pkill -9 python3`
- [ ] error modal「韌體寫入失敗」
- [ ] errorCode `FW_UPGRADE_*`
- [ ] 「重試」按鈕
### 2.4 #5 disconnect_during_op10 分鐘)
**注入**flashing stage 時、**拔 USB**
- [ ] error modal「裝置已斷開」
- [ ] **brick warning role=note**
- [ ] **無 Retry 按鈕**
- [ ] **Contact Support destructive 按鈕**
- [ ] 點 Contact Support、`xdg-open` 預設 mail handler 應開(如 Thunderbird
### 2.5 #6 timeout10 分鐘)
- [ ] 拔 USB 後等 > 60sKL520/ > 200sKL720
- [ ] 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 <PID>`
- [ ] **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 SIGINTCtrl+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 版本。
**完成簽核**________________ 日期________________

View File

@ -0,0 +1,264 @@
# M9-5 macOS 實機驗證 checklist
> 對應 plan`../m9-5-validation-plan.md`
> 平台macOS 14.xApple 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 情境 AKL520 KDP1 → KDP2 完整 4-stage30 分鐘)
**前置**:插 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 情境 BKL520 KDP2 short-circuit 3-stage15 分鐘)
**前置**:拔 KDP1 dongle、插 KL520 KDP2v2.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 情境 CKL720 升級15 分鐘)
**前置**:插 KL720 dongle
- [ ] 卡片 badge 顯示綠或黃
- [ ] 若為黃、點升級
- [ ] confirm modal 預估時間「3 分鐘」180 秒)
- [ ] progress 序列3 stageKL720 預燒 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_found5 分鐘)
- [ ] 拔 USB
- [ ] 立刻在 Devices 頁試圖點升級按鈕(如果卡片還在)
- [ ] 預期 error modal「找不到裝置」+「重新插拔後重試」按鈕
### 2.2 #3 loader_write_failed10 分鐘)
**注入**:升級進入 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_failed10 分鐘)
**注入**:升級進入 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 timeout10 分鐘)
**注入**:較難在實機重現、可:
- 拔 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 <PID>`
- [ ] **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 infodevice 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`
**完成簽核**________________ 日期________________

View File

@ -0,0 +1,234 @@
# M9-5 Windows 實機驗證 checklist
> 對應 plan`../m9-5-validation-plan.md`
> 平台Windows 10 / 11x86_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 driverinstaller 應已處理)
- [ ] Chrome / Edge 任一最新版開啟、F12 DevTools 準備好
- [ ] Console / PowerShell 開好
---
## 1. Functional 升級流程驗證90 分鐘)
### 1.1 情境 AKL520 KDP1 → KDP2 完整 4-stage30 分鐘)
**前置**:插 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 情境 BKL520 KDP2 short-circuit15 分鐘)
- [ ] 黃色 badge / 點升級
- [ ] **3 stage 序列**(沒 loading
- [ ] 60 秒內完成(**實測**______ 秒)
### 1.3 情境 CKL720 升級15 分鐘)
- [ ] 點升級、confirm 顯示「3 分鐘」
- [ ] 3 stageKL720 走 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_found5 分鐘)
- [ ] 拔 USB、點升級
- [ ] error modal「找不到裝置」
### 2.2 #3 loader_write_failed10 分鐘)
**注入**loading stage 時、Task Manager 強殺 `python.exe`(或 PowerShell 跑 `Stop-Process -Name python -Force`
- [ ] error modal「引導程式載入失敗」
- [ ] 「拔插後重試」按鈕
- [ ] **無 brick warning**recoverable
### 2.3 #4 upgrade_mid_failed10 分鐘)
**注入**flashing stage 時、強殺 python
- [ ] error modal「韌體寫入失敗」
- [ ] errorCode `FW_UPGRADE_*`
- [ ] 「重試」按鈕
### 2.4 #5 disconnect_during_op10 分鐘)
**注入**flashing stage 時、**拔 USB**
- [ ] error modal「裝置已斷開」
- [ ] **brick warning role=note**
- [ ] **無 Retry 按鈕**
- [ ] **Contact Support destructive 按鈕**
- [ ] 點 Contact Support、開 Outlook / 預設 mail handler
### 2.5 #6 timeout10 分鐘)
- [ ] 拔 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 infodevice 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]」前綴方便分流。
**完成簽核**________________ 日期________________

View File

@ -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 §6aWails
* modal
*
* frontend modal force-quit modal
* M9-12 spec **** M9-12
*
*
* Owner: Testing Agent (M9-5)
* Last reviewed: 2026-05-25
* Status: SKELETONM9-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 bindingwindow.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
// - chipKL520 / KL720
// - directionupgrade / downgrade
// - stageflashing / 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-12force-quit modal 第二層 FORCE 確認字串 input 嚴格 === 比對');
it.todo('M9-12第二層輸入「force」小寫不通過防誤觸');
it.todo('M9-12「繼續等待」按鈕關閉 force-quit modal、不呼 ConfirmForceClose');
it.todo('M9-12firmware 升級完成後 modal 自動關閉');
});
// ──────────────────────────────────────────────────────────────────────
// 補充:對 Go 端 close-guard 測試的 cross-reference
// ──────────────────────────────────────────────────────────────────────
describe('Go 端 close-guard test 已涵蓋cross-ref', () => {
it('cross-refvisiona-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 trueprevent 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 自動化腳本對照表)
});
});

View File

@ -368,7 +368,19 @@
- 2 nice-to-have Suggestionmailto 地址抽常數 / 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.ts357 行、4 cases
- firmware-upgrade-error-recovery.spec.ts356 行、4 cases + 8 reason it.each
- firmware-r-fw-11-modal-not-closable.spec.ts303 行、6 cases
- wails-onbeforeclose-firmware-active.spec.ts217 行、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 天 P0macOS+Win+Linux × KL520+KL720 happy path、~3h/ 第 2 天 P1R-FW-11 + 失敗注入、4h/ 第 3 天 P2SIGTERM + Wails OnBeforeClose、2-3h
- **使用者下週執行**、跑完依結果決定 A 階段是否交付pass → reviewer 收尾 → 進 B 階段 / fail → 派對應 sub-agent 修)
- [ ] M9-5 使用者下週實機執行macOS/Windows/Linux 三平台 × KL520+KL720
- [ ] M9-6 ~ M9-13B 階段擴展)
---

View File

@ -8,6 +8,12 @@ package ws
//
// 目的:保證 /ws/devices/:id/firmware-progress endpoint 把 client 正確
// join 到 "firmware:<deviceID>" room、且 broadcast 能送達。
//
// MJ3M9-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 的 roomschema 對齊 TDD §4.2
hub.BroadcastToRoom("firmware:kl520-0", map[string]interface{}{
"type": "firmware:progress",
"type": "firmware_progress",
"deviceId": "kl520-0",
"stage": "preparing",
"percent": 10,
})