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>
174 lines
4.6 KiB
Go
174 lines
4.6 KiB
Go
package ws
|
||
|
||
// firmware_ws_test.go — M9-4 hot-fix smoke test
|
||
//
|
||
// 對稱 system_ws_integration_test.go:啟 httptest server 掛
|
||
// FirmwareProgressHandler,用 gorilla WebSocket client 真的連線進去,
|
||
// 然後 hub.BroadcastToRoom("firmware:<deviceID>", ...) 驗證 client 收到。
|
||
//
|
||
// 目的:保證 /ws/devices/:id/firmware-progress endpoint 把 client 正確
|
||
// join 到 "firmware:<deviceID>" 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"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
func TestFirmwareProgressHandler_ReceivesBroadcast(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
hub := NewHub()
|
||
go hub.Run()
|
||
|
||
r := gin.New()
|
||
r.GET("/ws/devices/:id/firmware-progress", FirmwareProgressHandler(hub))
|
||
|
||
srv := httptest.NewServer(r)
|
||
defer srv.Close()
|
||
|
||
const deviceID = "kl520-0"
|
||
room := "firmware:" + deviceID
|
||
|
||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/devices/" + deviceID + "/firmware-progress"
|
||
dialer := websocket.DefaultDialer
|
||
conn, _, err := dialer.Dial(wsURL, http.Header{})
|
||
if err != nil {
|
||
t.Fatalf("dial: %v", err)
|
||
}
|
||
defer conn.Close()
|
||
|
||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||
|
||
// 等 hub 吸收 Register(最多 500 ms)
|
||
deadline := time.Now().Add(500 * time.Millisecond)
|
||
for time.Now().Before(deadline) {
|
||
hub.mu.RLock()
|
||
n := len(hub.rooms[room])
|
||
hub.mu.RUnlock()
|
||
if n > 0 {
|
||
break
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
|
||
// 廣播 firmware progress 事件(schema 對齊 TDD §4.2 + handler 端
|
||
// firmwareProgressMessage struct)。
|
||
hub.BroadcastToRoom(room, map[string]interface{}{
|
||
"type": "firmware_progress",
|
||
"deviceId": deviceID,
|
||
"stage": "flashing",
|
||
"percent": 42,
|
||
})
|
||
|
||
_, data, err := conn.ReadMessage()
|
||
if err != nil {
|
||
t.Fatalf("read: %v", err)
|
||
}
|
||
|
||
var got map[string]interface{}
|
||
if err := json.Unmarshal(data, &got); err != nil {
|
||
t.Fatalf("json: %v; raw=%s", err, string(data))
|
||
}
|
||
if got["type"] != "firmware_progress" {
|
||
t.Errorf("wrong type: %+v", got)
|
||
}
|
||
if got["deviceId"] != deviceID {
|
||
t.Errorf("wrong deviceId: %+v", got)
|
||
}
|
||
if got["stage"] != "flashing" {
|
||
t.Errorf("wrong stage: %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestFirmwareProgressHandler_RoomIsolation 驗證不同 deviceID 的 client
|
||
// 不會收到對方 room 的訊息(room key 帶 deviceID 的關鍵保證)。
|
||
func TestFirmwareProgressHandler_RoomIsolation(t *testing.T) {
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
hub := NewHub()
|
||
go hub.Run()
|
||
|
||
r := gin.New()
|
||
r.GET("/ws/devices/:id/firmware-progress", FirmwareProgressHandler(hub))
|
||
|
||
srv := httptest.NewServer(r)
|
||
defer srv.Close()
|
||
|
||
dialFor := func(deviceID string) *websocket.Conn {
|
||
t.Helper()
|
||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") +
|
||
"/ws/devices/" + deviceID + "/firmware-progress"
|
||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{})
|
||
if err != nil {
|
||
t.Fatalf("dial %s: %v", deviceID, err)
|
||
}
|
||
return conn
|
||
}
|
||
|
||
connA := dialFor("kl520-0")
|
||
defer connA.Close()
|
||
connB := dialFor("kl720-1")
|
||
defer connB.Close()
|
||
|
||
// 等兩個 room 都被 hub 吸收
|
||
wantRooms := []string{"firmware:kl520-0", "firmware:kl720-1"}
|
||
deadline := time.Now().Add(500 * time.Millisecond)
|
||
for time.Now().Before(deadline) {
|
||
ok := true
|
||
hub.mu.RLock()
|
||
for _, room := range wantRooms {
|
||
if len(hub.rooms[room]) == 0 {
|
||
ok = false
|
||
break
|
||
}
|
||
}
|
||
hub.mu.RUnlock()
|
||
if ok {
|
||
break
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
|
||
// 只 broadcast 到 kl520-0 的 room(schema 對齊 TDD §4.2)
|
||
hub.BroadcastToRoom("firmware:kl520-0", map[string]interface{}{
|
||
"type": "firmware_progress",
|
||
"deviceId": "kl520-0",
|
||
"stage": "preparing",
|
||
"percent": 10,
|
||
})
|
||
|
||
// connA 應該收到
|
||
_ = connA.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||
_, data, err := connA.ReadMessage()
|
||
if err != nil {
|
||
t.Fatalf("connA read: %v", err)
|
||
}
|
||
var got map[string]interface{}
|
||
if err := json.Unmarshal(data, &got); err != nil {
|
||
t.Fatalf("connA json: %v", err)
|
||
}
|
||
if got["deviceId"] != "kl520-0" {
|
||
t.Errorf("connA got wrong deviceId: %+v", got)
|
||
}
|
||
|
||
// connB 不該收到 — 設短 deadline,預期 timeout
|
||
_ = connB.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||
_, _, err = connB.ReadMessage()
|
||
if err == nil {
|
||
t.Errorf("connB should not have received message but did")
|
||
}
|
||
}
|