1package app
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/bnema/nefercap/internal/adapters/ffmpeg"
17 "github.com/bnema/nefercap/internal/core"
18 portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
19 "github.com/bnema/nefercap/internal/ports"
20)
21
22// A generated source mock supplies pixels; the production core and FFmpeg
23// writer run together, exercising cancellation at their owning boundary.
24func TestRecordingFinalizesAfterCancellation(t *testing.T) {
25 testRecordingFinalizes(t, "cancel")
26}
27
28func TestRecordingFinalizesAfterLateCaptureFailure(t *testing.T) {
29 testRecordingFinalizes(t, "capture")
30}
31
32func TestRecordingFinalizesAfterLateGeometryFailure(t *testing.T) {
33 testRecordingFinalizes(t, "geometry")
34}
35
36func testRecordingFinalizes(t *testing.T, failure string) {
37 t.Helper()
38 for _, binary := range []string{"ffmpeg", "ffprobe"} {
39 if _, err := exec.LookPath(binary); err != nil {
40 t.Skip(binary + " unavailable")
41 }
42 }
43 ctx, cancel := context.WithCancel(context.Background())
44 defer cancel()
45 frame := ports.Frame{Pixels: make([]byte, 64*48*4), Width: 64, Height: 48, Stride: 64 * 4, Format: ports.XRGB8888}
46 for i := 0; i < len(frame.Pixels); i += 4 {
47 frame.Pixels[i+2] = 255
48 }
49 target := ports.Target{OutputID: 1}
50 source := portsmocks.NewMockSource(t)
51 n := 0
52 captureErr := errors.New("capture session lost")
53 source.EXPECT().Capture(ctx, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
54 n++
55 if n == 5 {
56 switch failure {
57 case "capture":
58 return ports.Frame{}, captureErr
59 case "geometry":
60 return ports.Frame{}, nil
61 default:
62 cancel()
63 return ports.Frame{}, context.Canceled
64 }
65 }
66 return frame, nil
67 })
68 path := filepath.Join(t.TempDir(), "recording.mp4")
69 video := ffmpeg.New()
70 runErr := core.New(source, nil, video).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}})
71 switch failure {
72 case "capture":
73 if !errors.Is(runErr, captureErr) {
74 t.Fatalf("capture failure lost: %v", runErr)
75 }
76 case "geometry":
77 if runErr == nil || !strings.Contains(runErr.Error(), "invalid frame dimensions") {
78 t.Fatalf("geometry failure lost: %v", runErr)
79 }
80 default:
81 if runErr != nil {
82 t.Fatal(runErr)
83 }
84 }
85 if !video.Saved {
86 t.Fatal("finalized recording not reported as saved")
87 }
88 var out bytes.Buffer
89 if err := finishCapture(&out, path, video.Saved, runErr); err != runErr || out.String() != path+"\n" {
90 t.Fatalf("retained path or recording error lost: %q %v", out.String(), err)
91 }
92 probeCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
93 defer stop()
94 data, err := exec.CommandContext(probeCtx, "ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", "-show_entries", "stream=codec_name,nb_read_frames", "-of", "json", path).Output()
95 if err != nil {
96 t.Fatal(err)
97 }
98 var result struct {
99 Streams []struct {
100 Codec string `json:"codec_name"`
101 Frames string `json:"nb_read_frames"`
102 }
103 }
104 if err := json.Unmarshal(data, &result); err != nil {
105 t.Fatal(err)
106 }
107 if len(result.Streams) != 1 || result.Streams[0].Codec != "h264" {
108 t.Fatalf("unexpected probe: %s", data)
109 }
110 frames, err := strconv.Atoi(result.Streams[0].Frames)
111 if err != nil || frames < 4 {
112 t.Fatalf("complete frames missing: %s", data)
113 }
114 info, err := os.Stat(path)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if info.Mode().Perm() != 0o600 {
119 t.Fatalf("permissions: %v", info.Mode())
120 }
121}