visionA/local-agent/server/internal/camera/ffmpeg_camera_test.go
jim800121chen e4d27594d6 fix(local-agent): 修 camera 即時推論開不了(macOS 攝影機權限 + 假成功)
三層疊加根因:
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>
2026-08-02 03:48:30 +08:00

93 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 1stdout 沒有任何 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 正常產出一張最小 JPEGSOI+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)
}
}