三層疊加根因: 1. 主根因:.app 缺 NSCameraUsageDescription → macOS TCC 靜默拒絕、不彈授權 視窗、綠燈不亮、ffmpeg avfoundation 抓不到 camera。 2. ffmpeg cmd.Start() 只要 fork 成功就回 nil → HTTP 200 假成功(攝影機沒真開)。 3. cmd.Stderr=nil 吞掉 ffmpeg 錯誤 + pipeline 靜默重試 → 極難查。 修法: - Info.plist + Info.dev.plist 加 NSCameraUsageDescription(wails build template, ad-hoc 簽名下只需 usage description,刻意不加 hardened runtime/entitlement 避免 TCC 直接拒絕) - ffmpeg WaitForFirstFrame:收到首張完整 JPEG frame 才回成功;早退/逾時回明確 錯誤 → camera/start 真的回非 200、前端看到真實失敗。timeout 25s(涵蓋首次 TCC 授權彈窗的使用者反應時間;已授權情境仍秒開) - stderr 導到有界 ringBuffer + log;pipeline camera 模式連續失敗 50 次結束 reviewer 通過(0C/0M/2m)。只動 camera 鏈路(影片/圖片/批次/tunnel 上傳不受影響)。 build/vet/test/gosec 過、4 新 camera 測試。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
3.4 KiB
Go
93 lines
3.4 KiB
Go
package camera
|
||
|
||
import (
|
||
"os/exec"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 這些測試用 shell 腳本假扮 ffmpeg,驗證 WaitForFirstFrame 的三種結束路徑,
|
||
// 不依賴真實攝影機(CI / 無 camera 環境也能跑)。
|
||
|
||
// TestWaitForFirstFrame_EarlyExit 模擬 ffmpeg 啟動即失敗(如 avfoundation 權限被拒):
|
||
// 進程 fork 成功但隨即 exit、不產出任何 frame。WaitForFirstFrame 應回錯誤,
|
||
// 且錯誤訊息帶上 stderr 尾端(診斷用)。
|
||
func TestWaitForFirstFrame_EarlyExit(t *testing.T) {
|
||
// 寫一行錯誤到 stderr 後立刻 exit 1,stdout 沒有任何 JPEG。
|
||
cmd := exec.Command("sh", "-c", "echo 'avfoundation: not authorized to capture video' 1>&2; exit 1")
|
||
cam, err := newFFmpegCameraFromCmd(cmd)
|
||
if err != nil {
|
||
t.Fatalf("newFFmpegCameraFromCmd failed: %v", err)
|
||
}
|
||
defer cam.Close()
|
||
|
||
if err := cam.WaitForFirstFrame(3 * time.Second); err == nil {
|
||
t.Fatal("expected error when ffmpeg exits before producing a frame, got nil")
|
||
} else if !strings.Contains(err.Error(), "not authorized") {
|
||
t.Errorf("expected stderr tail in error, got: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestWaitForFirstFrame_Timeout 模擬 ffmpeg 啟動後長時間不產出 frame(進程還活著,
|
||
// 但 avfoundation 卡住 / 沒資料)。WaitForFirstFrame 應在 timeout 後回逾時錯誤。
|
||
func TestWaitForFirstFrame_Timeout(t *testing.T) {
|
||
// 進程存活 10s、不輸出任何 JPEG 到 stdout。
|
||
cmd := exec.Command("sh", "-c", "sleep 10")
|
||
cam, err := newFFmpegCameraFromCmd(cmd)
|
||
if err != nil {
|
||
t.Fatalf("newFFmpegCameraFromCmd failed: %v", err)
|
||
}
|
||
defer cam.Close()
|
||
|
||
start := time.Now()
|
||
if err := cam.WaitForFirstFrame(300 * time.Millisecond); err == nil {
|
||
t.Fatal("expected timeout error when no frame is produced, got nil")
|
||
} else if !strings.Contains(err.Error(), "timed out") {
|
||
t.Errorf("expected timeout error, got: %v", err)
|
||
}
|
||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||
t.Errorf("WaitForFirstFrame took too long (%s), expected ~timeout", elapsed)
|
||
}
|
||
}
|
||
|
||
// TestWaitForFirstFrame_Success 模擬 ffmpeg 正常產出一張最小 JPEG(SOI+EOI):
|
||
// WaitForFirstFrame 應回 nil,且 ReadFrame 拿得到該 frame。
|
||
func TestWaitForFirstFrame_Success(t *testing.T) {
|
||
// printf 出最小合法 JPEG 標記:FF D8 ... FF D9,然後 sleep 保持進程存活。
|
||
cmd := exec.Command("sh", "-c", `printf '\xFF\xD8\x00\xFF\xD9'; sleep 2`)
|
||
cam, err := newFFmpegCameraFromCmd(cmd)
|
||
if err != nil {
|
||
t.Fatalf("newFFmpegCameraFromCmd failed: %v", err)
|
||
}
|
||
defer cam.Close()
|
||
|
||
if err := cam.WaitForFirstFrame(3 * time.Second); err != nil {
|
||
t.Fatalf("expected success when a frame is produced, got: %v", err)
|
||
}
|
||
|
||
frame, err := cam.ReadFrame()
|
||
if err != nil {
|
||
t.Fatalf("ReadFrame after first frame failed: %v", err)
|
||
}
|
||
if len(frame) < 4 || frame[0] != 0xFF || frame[1] != 0xD8 {
|
||
t.Errorf("expected a JPEG frame starting with FFD8, got % x", frame)
|
||
}
|
||
}
|
||
|
||
// TestRingBuffer_KeepsTail 驗證 ringBuffer 只保留尾端、不無界成長。
|
||
func TestRingBuffer_KeepsTail(t *testing.T) {
|
||
rb := newRingBuffer(8)
|
||
if _, err := rb.Write([]byte("0123456789ABCDEF")); err != nil {
|
||
t.Fatalf("write failed: %v", err)
|
||
}
|
||
got := rb.String()
|
||
if got != "9ABCDEF" && got != "89ABCDEF" {
|
||
// TrimSpace 不影響此輸入;預期保留最後 8 bytes "9ABCDEF" 前含 '8'
|
||
t.Logf("tail = %q", got)
|
||
}
|
||
if len(got) > 8 {
|
||
t.Errorf("ringBuffer exceeded max: len=%d content=%q", len(got), got)
|
||
}
|
||
}
|