detached e272971

feat(core): add capture workflow and CLI parser

commit
e2729710a8af0dfe2f219164198383ca987f3774
author
brice · · 2026-09-30 08:05 UTC
committer
brice · · 2026-09-30 14:48 UTC
parent
3cdbdaa
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +944 -0
A internal/adapters/cli/cli.go +209 -0
@@ -0,0 +1,209 @@
1+// Package cli parses the command line into capture options.
2+package cli
3+
4+import (
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "math"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// Command is the selected subcommand.
18+type Command string
19+
20+const (
21+ Outputs Command = "outputs"
22+ Screenshot Command = "screenshot"
23+ Record Command = "record"
24+ GUI Command = "gui"
25+)
26+
27+// DefaultFPS is the recording frame rate when -fps is not given.
28+const DefaultFPS = 30
29+
30+// ErrUsage wraps every command-line usage error.
31+var ErrUsage = errors.New("usage error")
32+
33+// Options is the parsed command line. Video is set only for Record.
34+type Options struct {
35+ Command Command
36+ Output string // output name; empty lets the caller choose
37+ Region ports.Region
38+ Path string
39+ Video ports.VideoSettings
40+ Duration time.Duration
41+ Debug bool
42+}
43+
44+// Usage describes the command line.
45+func Usage() string {
46+ return `Usage: nefercap [command] [flags]
47+
48+Commands:
49+ gui open the capture control panel (default)
50+ outputs list outputs
51+ screenshot save a PNG screenshot
52+ record record silent video until interrupted or -duration elapses
53+
54+Flags:
55+ -debug enable debug logging (all commands)
56+ -output NAME output name (screenshot, record)
57+ -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
58+ -file PATH new file to write, required (screenshot, record)
59+ -fps N frames per second, 1..120 (record, default 30)
60+ -size WxH scaled video size, both even (record, default source size)
61+ -duration D recording length such as 30s; 0 records until interrupted (record)
62+`
63+}
64+
65+// Parse parses args (without the program name). Help requests print Usage to
66+// out and return flag.ErrHelp; other failures wrap ErrUsage.
67+func Parse(args []string, out io.Writer) (Options, error) {
68+ if out == nil {
69+ out = io.Discard
70+ }
71+ opts := Options{Command: GUI}
72+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
73+ switch cmd := Command(args[0]); cmd {
74+ case Outputs, Screenshot, Record, GUI:
75+ opts.Command = cmd
76+ args = args[1:]
77+ default:
78+ return Options{}, usageErr("unknown command %q", args[0])
79+ }
80+ }
81+
82+ fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError)
83+ fs.SetOutput(io.Discard)
84+ fs.BoolVar(&opts.Debug, "debug", false, "")
85+ var region, size string
86+ fps := DefaultFPS
87+ capture := opts.Command == Screenshot || opts.Command == Record
88+ if capture {
89+ fs.StringVar(&opts.Output, "output", "", "")
90+ fs.StringVar(&region, "region", "", "")
91+ fs.StringVar(&opts.Path, "file", "", "")
92+ }
93+ if opts.Command == Record {
94+ fs.IntVar(&fps, "fps", DefaultFPS, "")
95+ fs.StringVar(&size, "size", "", "")
96+ fs.DurationVar(&opts.Duration, "duration", 0, "")
97+ }
98+
99+ if err := fs.Parse(args); err != nil {
100+ if errors.Is(err, flag.ErrHelp) {
101+ _, _ = io.WriteString(out, Usage())
102+ return Options{}, flag.ErrHelp
103+ }
104+ return Options{}, fmt.Errorf("%w: %w", ErrUsage, err)
105+ }
106+ if fs.NArg() > 0 {
107+ return Options{}, usageErr("unexpected argument %q", fs.Arg(0))
108+ }
109+ if !capture {
110+ return opts, nil
111+ }
112+
113+ if opts.Path == "" {
114+ return Options{}, usageErr("-file is required")
115+ }
116+ if region != "" {
117+ r, err := parseRegion(region)
118+ if err != nil {
119+ return Options{}, usageErr("invalid -region %q: %v", region, err)
120+ }
121+ opts.Region = r
122+ }
123+ if opts.Command == Record {
124+ if fps < 1 || fps > ports.MaxFPS {
125+ return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS)
126+ }
127+ opts.Video.FPS = fps
128+ if size != "" {
129+ w, h, err := parseSize(size)
130+ if err != nil {
131+ return Options{}, usageErr("invalid -size %q: %v", size, err)
132+ }
133+ if w%2 != 0 || h%2 != 0 {
134+ return Options{}, usageErr("invalid -size %q: width and height must be even", size)
135+ }
136+ if w > ports.MaxDimension || h > ports.MaxDimension || w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
137+ return Options{}, usageErr("invalid -size %q: exceeds frame limits", size)
138+ }
139+ opts.Video.Width, opts.Video.Height = w, h
140+ }
141+ if opts.Duration < 0 {
142+ return Options{}, usageErr("invalid -duration %s: must not be negative", opts.Duration)
143+ }
144+ }
145+ return opts, nil
146+}
147+
148+func usageErr(format string, a ...any) error {
149+ return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...))
150+}
151+
152+// parseRegion parses "X,Y,WxH" with nonnegative X,Y and positive W,H, all
153+// within int32.
154+func parseRegion(s string) (ports.Region, error) {
155+ xy, wh, ok := strings.Cut(s, "x")
156+ parts := strings.Split(xy, ",")
157+ if !ok || len(parts) != 3 {
158+ return ports.Region{}, errors.New("want X,Y,WxH")
159+ }
160+ h, err := parseInt(wh, 1)
161+ if err != nil {
162+ return ports.Region{}, err
163+ }
164+ x, err := parseInt(parts[0], 0)
165+ if err != nil {
166+ return ports.Region{}, err
167+ }
168+ y, err := parseInt(parts[1], 0)
169+ if err != nil {
170+ return ports.Region{}, err
171+ }
172+ w, err := parseInt(parts[2], 1)
173+ if err != nil {
174+ return ports.Region{}, err
175+ }
176+ if int64(x)+int64(w) > math.MaxInt32 || int64(y)+int64(h) > math.MaxInt32 {
177+ return ports.Region{}, errors.New("region exceeds int32 bounds")
178+ }
179+ return ports.Region{X: x, Y: y, Width: w, Height: h}, nil
180+}
181+
182+func parseSize(s string) (w, h int, err error) {
183+ ws, hs, ok := strings.Cut(s, "x")
184+ if !ok {
185+ return 0, 0, errors.New("want WxH")
186+ }
187+ if w, err = parseInt(ws, 1); err != nil {
188+ return 0, 0, err
189+ }
190+ if h, err = parseInt(hs, 1); err != nil {
191+ return 0, 0, err
192+ }
193+ return w, h, nil
194+}
195+
196+// parseInt accepts plain decimal digits only, at least min, at most MaxInt32.
197+func parseInt(s string, min int) (int, error) {
198+ if s == "" || strings.TrimLeft(s, "0123456789") != "" {
199+ return 0, fmt.Errorf("%q is not a decimal number", s)
200+ }
201+ v, err := strconv.ParseInt(s, 10, 32)
202+ if err != nil {
203+ return 0, fmt.Errorf("%q is out of range", s)
204+ }
205+ if int(v) < min {
206+ return 0, fmt.Errorf("%q must be at least %d", s, min)
207+ }
208+ return int(v), nil
209+}
@@ -0,0 +1,209 @@
1+// Package cli parses the command line into capture options.
2+package cli
3+
4+import (
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "math"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// Command is the selected subcommand.
18+type Command string
19+
20+const (
21+ Outputs Command = "outputs"
22+ Screenshot Command = "screenshot"
23+ Record Command = "record"
24+ GUI Command = "gui"
25+)
26+
27+// DefaultFPS is the recording frame rate when -fps is not given.
28+const DefaultFPS = 30
29+
30+// ErrUsage wraps every command-line usage error.
31+var ErrUsage = errors.New("usage error")
32+
33+// Options is the parsed command line. Video is set only for Record.
34+type Options struct {
35+ Command Command
36+ Output string // output name; empty lets the caller choose
37+ Region ports.Region
38+ Path string
39+ Video ports.VideoSettings
40+ Duration time.Duration
41+ Debug bool
42+}
43+
44+// Usage describes the command line.
45+func Usage() string {
46+ return `Usage: nefercap [command] [flags]
47+
48+Commands:
49+ gui open the capture control panel (default)
50+ outputs list outputs
51+ screenshot save a PNG screenshot
52+ record record silent video until interrupted or -duration elapses
53+
54+Flags:
55+ -debug enable debug logging (all commands)
56+ -output NAME output name (screenshot, record)
57+ -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
58+ -file PATH new file to write, required (screenshot, record)
59+ -fps N frames per second, 1..120 (record, default 30)
60+ -size WxH scaled video size, both even (record, default source size)
61+ -duration D recording length such as 30s; 0 records until interrupted (record)
62+`
63+}
64+
65+// Parse parses args (without the program name). Help requests print Usage to
66+// out and return flag.ErrHelp; other failures wrap ErrUsage.
67+func Parse(args []string, out io.Writer) (Options, error) {
68+ if out == nil {
69+ out = io.Discard
70+ }
71+ opts := Options{Command: GUI}
72+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
73+ switch cmd := Command(args[0]); cmd {
74+ case Outputs, Screenshot, Record, GUI:
75+ opts.Command = cmd
76+ args = args[1:]
77+ default:
78+ return Options{}, usageErr("unknown command %q", args[0])
79+ }
80+ }
81+
82+ fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError)
83+ fs.SetOutput(io.Discard)
84+ fs.BoolVar(&opts.Debug, "debug", false, "")
85+ var region, size string
86+ fps := DefaultFPS
87+ capture := opts.Command == Screenshot || opts.Command == Record
88+ if capture {
89+ fs.StringVar(&opts.Output, "output", "", "")
90+ fs.StringVar(&region, "region", "", "")
91+ fs.StringVar(&opts.Path, "file", "", "")
92+ }
93+ if opts.Command == Record {
94+ fs.IntVar(&fps, "fps", DefaultFPS, "")
95+ fs.StringVar(&size, "size", "", "")
96+ fs.DurationVar(&opts.Duration, "duration", 0, "")
97+ }
98+
99+ if err := fs.Parse(args); err != nil {
100+ if errors.Is(err, flag.ErrHelp) {
101+ _, _ = io.WriteString(out, Usage())
102+ return Options{}, flag.ErrHelp
103+ }
104+ return Options{}, fmt.Errorf("%w: %w", ErrUsage, err)
105+ }
106+ if fs.NArg() > 0 {
107+ return Options{}, usageErr("unexpected argument %q", fs.Arg(0))
108+ }
109+ if !capture {
110+ return opts, nil
111+ }
112+
113+ if opts.Path == "" {
114+ return Options{}, usageErr("-file is required")
115+ }
116+ if region != "" {
117+ r, err := parseRegion(region)
118+ if err != nil {
119+ return Options{}, usageErr("invalid -region %q: %v", region, err)
120+ }
121+ opts.Region = r
122+ }
123+ if opts.Command == Record {
124+ if fps < 1 || fps > ports.MaxFPS {
125+ return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS)
126+ }
127+ opts.Video.FPS = fps
128+ if size != "" {
129+ w, h, err := parseSize(size)
130+ if err != nil {
131+ return Options{}, usageErr("invalid -size %q: %v", size, err)
132+ }
133+ if w%2 != 0 || h%2 != 0 {
134+ return Options{}, usageErr("invalid -size %q: width and height must be even", size)
135+ }
136+ if w > ports.MaxDimension || h > ports.MaxDimension || w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
137+ return Options{}, usageErr("invalid -size %q: exceeds frame limits", size)
138+ }
139+ opts.Video.Width, opts.Video.Height = w, h
140+ }
141+ if opts.Duration < 0 {
142+ return Options{}, usageErr("invalid -duration %s: must not be negative", opts.Duration)
143+ }
144+ }
145+ return opts, nil
146+}
147+
148+func usageErr(format string, a ...any) error {
149+ return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...))
150+}
151+
152+// parseRegion parses "X,Y,WxH" with nonnegative X,Y and positive W,H, all
153+// within int32.
154+func parseRegion(s string) (ports.Region, error) {
155+ xy, wh, ok := strings.Cut(s, "x")
156+ parts := strings.Split(xy, ",")
157+ if !ok || len(parts) != 3 {
158+ return ports.Region{}, errors.New("want X,Y,WxH")
159+ }
160+ h, err := parseInt(wh, 1)
161+ if err != nil {
162+ return ports.Region{}, err
163+ }
164+ x, err := parseInt(parts[0], 0)
165+ if err != nil {
166+ return ports.Region{}, err
167+ }
168+ y, err := parseInt(parts[1], 0)
169+ if err != nil {
170+ return ports.Region{}, err
171+ }
172+ w, err := parseInt(parts[2], 1)
173+ if err != nil {
174+ return ports.Region{}, err
175+ }
176+ if int64(x)+int64(w) > math.MaxInt32 || int64(y)+int64(h) > math.MaxInt32 {
177+ return ports.Region{}, errors.New("region exceeds int32 bounds")
178+ }
179+ return ports.Region{X: x, Y: y, Width: w, Height: h}, nil
180+}
181+
182+func parseSize(s string) (w, h int, err error) {
183+ ws, hs, ok := strings.Cut(s, "x")
184+ if !ok {
185+ return 0, 0, errors.New("want WxH")
186+ }
187+ if w, err = parseInt(ws, 1); err != nil {
188+ return 0, 0, err
189+ }
190+ if h, err = parseInt(hs, 1); err != nil {
191+ return 0, 0, err
192+ }
193+ return w, h, nil
194+}
195+
196+// parseInt accepts plain decimal digits only, at least min, at most MaxInt32.
197+func parseInt(s string, min int) (int, error) {
198+ if s == "" || strings.TrimLeft(s, "0123456789") != "" {
199+ return 0, fmt.Errorf("%q is not a decimal number", s)
200+ }
201+ v, err := strconv.ParseInt(s, 10, 32)
202+ if err != nil {
203+ return 0, fmt.Errorf("%q is out of range", s)
204+ }
205+ if int(v) < min {
206+ return 0, fmt.Errorf("%q must be at least %d", s, min)
207+ }
208+ return int(v), nil
209+}
A internal/adapters/cli/cli_test.go +138 -0
@@ -0,0 +1,138 @@
1+package cli_test
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "flag"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/nefercap/internal/adapters/cli"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+func TestParseValid(t *testing.T) {
18+ cases := []struct {
19+ name string
20+ args []string
21+ want cli.Options
22+ }{
23+ {"default gui", nil, cli.Options{Command: cli.GUI}},
24+ {"gui debug", []string{"-debug"}, cli.Options{Command: cli.GUI, Debug: true}},
25+ {"explicit gui", []string{"gui"}, cli.Options{Command: cli.GUI}},
26+ {"outputs", []string{"outputs"}, cli.Options{Command: cli.Outputs}},
27+ {"outputs debug", []string{"outputs", "-debug"}, cli.Options{Command: cli.Outputs, Debug: true}},
28+ {"screenshot", []string{"screenshot", "-file", "a.png"},
29+ cli.Options{Command: cli.Screenshot, Path: "a.png"}},
30+ {"screenshot all", []string{"screenshot", "-output", "DP-1", "-region", "10,20,300x400", "-file", "a.png", "-debug"},
31+ cli.Options{Command: cli.Screenshot, Output: "DP-1", Path: "a.png", Debug: true,
32+ Region: ports.Region{X: 10, Y: 20, Width: 300, Height: 400}}},
33+ {"record defaults", []string{"record", "-file", "a.mkv"},
34+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
35+ {"record all", []string{"record", "-file", "a.mkv", "-fps", "60", "-size", "1280x720", "-duration", "1m30s", "-output", "eDP-1", "-region", "0,0,640x480"},
36+ cli.Options{Command: cli.Record, Path: "a.mkv", Output: "eDP-1", Duration: 90 * time.Second,
37+ Region: ports.Region{Width: 640, Height: 480},
38+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720}}},
39+ {"record zero duration", []string{"record", "-file", "a.mkv", "-duration", "0"},
40+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
41+ {"record fps bounds", []string{"record", "-file", "a.mkv", "-fps", "120"},
42+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 120}}},
43+ {"double dash flag", []string{"record", "--file", "a.mkv", "--fps=1"},
44+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 1}}},
45+ }
46+ for _, tc := range cases {
47+ t.Run(tc.name, func(t *testing.T) {
48+ var out bytes.Buffer
49+ got, err := cli.Parse(tc.args, &out)
50+ require.NoError(t, err)
51+ assert.Equal(t, tc.want, got)
52+ assert.Empty(t, out.String())
53+ })
54+ }
55+}
56+
57+func TestParseUsageErrors(t *testing.T) {
58+ cases := map[string][]string{
59+ "unknown command": {"snap"},
60+ "positional after command": {"outputs", "extra"},
61+ "positional gui": {"extra"},
62+ "positional after flags": {"record", "-file", "a.mkv", "extra"},
63+ "unknown flag": {"-nope"},
64+ "unknown flag in command": {"outputs", "-nope"},
65+ "screenshot missing file": {"screenshot"},
66+ "record missing file": {"record", "-fps", "30"},
67+ "empty file": {"screenshot", "-file", ""},
68+ "capture flag on gui": {"-file", "a.png"},
69+ "capture flag on outputs": {"outputs", "-output", "DP-1"},
70+ "fps on screenshot": {"screenshot", "-file", "a.png", "-fps", "30"},
71+ "duration on screenshot": {"screenshot", "-file", "a.png", "-duration", "1s"},
72+ "size on screenshot": {"screenshot", "-file", "a.png", "-size", "2x2"},
73+ "fps zero": {"record", "-file", "a", "-fps", "0"},
74+ "fps high": {"record", "-file", "a", "-fps", "121"},
75+ "fps text": {"record", "-file", "a", "-fps", "fast"},
76+ "size odd width": {"record", "-file", "a", "-size", "641x480"},
77+ "size odd height": {"record", "-file", "a", "-size", "640x481"},
78+ "size zero": {"record", "-file", "a", "-size", "0x0"},
79+ "size too large": {"record", "-file", "a", "-size", "16386x2"},
80+ "size frame too large": {"record", "-file", "a", "-size", "16384x16384"},
81+ "size missing height": {"record", "-file", "a", "-size", "640"},
82+ "size negative": {"record", "-file", "a", "-size", "-2x-2"},
83+ "size junk": {"record", "-file", "a", "-size", "axb"},
84+ "size signed": {"record", "-file", "a", "-size", "+2x2"},
85+ "duration negative": {"record", "-file", "a", "-duration", "-1s"},
86+ "duration bare number": {"record", "-file", "a", "-duration", "5"},
87+ "region empty parts": {"screenshot", "-file", "a", "-region", ",,x"},
88+ "region missing size": {"screenshot", "-file", "a", "-region", "1,2"},
89+ "region missing y": {"screenshot", "-file", "a", "-region", "1,10x10"},
90+ "region extra part": {"screenshot", "-file", "a", "-region", "1,2,3,10x10"},
91+ "region negative x": {"screenshot", "-file", "a", "-region", "-1,0,10x10"},
92+ "region negative y": {"screenshot", "-file", "a", "-region", "0,-1,10x10"},
93+ "region zero width": {"screenshot", "-file", "a", "-region", "0,0,0x10"},
94+ "region zero height": {"screenshot", "-file", "a", "-region", "0,0,10x0"},
95+ "region overflow": {"screenshot", "-file", "a", "-region", "0,0,99999999999x1"},
96+ "region sum overflow": {"screenshot", "-file", "a", "-region", "2147483647,0,1x1"},
97+ "region signed": {"screenshot", "-file", "a", "-region", "+1,0,10x10"},
98+ "region uppercase X": {"screenshot", "-file", "a", "-region", "0,0,10X10"},
99+ "region spaces": {"screenshot", "-file", "a", "-region", "0, 0, 10x10"},
100+ }
101+ for name, args := range cases {
102+ t.Run(name, func(t *testing.T) {
103+ var out bytes.Buffer
104+ got, err := cli.Parse(args, &out)
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, cli.ErrUsage)
107+ assert.NotErrorIs(t, err, flag.ErrHelp)
108+ assert.Equal(t, cli.Options{}, got)
109+ assert.Empty(t, out.String(), "errors are reported by the caller")
110+ })
111+ }
112+}
113+
114+func TestParseErrorNamesProblem(t *testing.T) {
115+ _, err := cli.Parse([]string{"record"}, nil)
116+ assert.ErrorContains(t, err, "-file is required")
117+ _, err = cli.Parse([]string{"snap"}, nil)
118+ assert.ErrorContains(t, err, `unknown command "snap"`)
119+ _, err = cli.Parse([]string{"screenshot", "-file", "a", "-region", "1,2"}, nil)
120+ assert.ErrorContains(t, err, "X,Y,WxH")
121+}
122+
123+func TestParseHelp(t *testing.T) {
124+ for _, args := range [][]string{{"-h"}, {"--help"}, {"-help"}, {"record", "--help"}, {"outputs", "-h"}} {
125+ var out bytes.Buffer
126+ got, err := cli.Parse(args, &out)
127+ assert.True(t, errors.Is(err, flag.ErrHelp), args)
128+ assert.Equal(t, cli.Options{}, got)
129+ assert.Equal(t, cli.Usage(), out.String())
130+ }
131+}
132+
133+func TestUsageMentionsEverything(t *testing.T) {
134+ u := cli.Usage()
135+ for _, s := range []string{"gui", "outputs", "screenshot", "record", "-output", "-region", "-file", "-fps", "-size", "-duration", "-debug"} {
136+ assert.Contains(t, u, s)
137+ }
138+}
@@ -0,0 +1,138 @@
1+package cli_test
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "flag"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/nefercap/internal/adapters/cli"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+func TestParseValid(t *testing.T) {
18+ cases := []struct {
19+ name string
20+ args []string
21+ want cli.Options
22+ }{
23+ {"default gui", nil, cli.Options{Command: cli.GUI}},
24+ {"gui debug", []string{"-debug"}, cli.Options{Command: cli.GUI, Debug: true}},
25+ {"explicit gui", []string{"gui"}, cli.Options{Command: cli.GUI}},
26+ {"outputs", []string{"outputs"}, cli.Options{Command: cli.Outputs}},
27+ {"outputs debug", []string{"outputs", "-debug"}, cli.Options{Command: cli.Outputs, Debug: true}},
28+ {"screenshot", []string{"screenshot", "-file", "a.png"},
29+ cli.Options{Command: cli.Screenshot, Path: "a.png"}},
30+ {"screenshot all", []string{"screenshot", "-output", "DP-1", "-region", "10,20,300x400", "-file", "a.png", "-debug"},
31+ cli.Options{Command: cli.Screenshot, Output: "DP-1", Path: "a.png", Debug: true,
32+ Region: ports.Region{X: 10, Y: 20, Width: 300, Height: 400}}},
33+ {"record defaults", []string{"record", "-file", "a.mkv"},
34+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
35+ {"record all", []string{"record", "-file", "a.mkv", "-fps", "60", "-size", "1280x720", "-duration", "1m30s", "-output", "eDP-1", "-region", "0,0,640x480"},
36+ cli.Options{Command: cli.Record, Path: "a.mkv", Output: "eDP-1", Duration: 90 * time.Second,
37+ Region: ports.Region{Width: 640, Height: 480},
38+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720}}},
39+ {"record zero duration", []string{"record", "-file", "a.mkv", "-duration", "0"},
40+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
41+ {"record fps bounds", []string{"record", "-file", "a.mkv", "-fps", "120"},
42+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 120}}},
43+ {"double dash flag", []string{"record", "--file", "a.mkv", "--fps=1"},
44+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 1}}},
45+ }
46+ for _, tc := range cases {
47+ t.Run(tc.name, func(t *testing.T) {
48+ var out bytes.Buffer
49+ got, err := cli.Parse(tc.args, &out)
50+ require.NoError(t, err)
51+ assert.Equal(t, tc.want, got)
52+ assert.Empty(t, out.String())
53+ })
54+ }
55+}
56+
57+func TestParseUsageErrors(t *testing.T) {
58+ cases := map[string][]string{
59+ "unknown command": {"snap"},
60+ "positional after command": {"outputs", "extra"},
61+ "positional gui": {"extra"},
62+ "positional after flags": {"record", "-file", "a.mkv", "extra"},
63+ "unknown flag": {"-nope"},
64+ "unknown flag in command": {"outputs", "-nope"},
65+ "screenshot missing file": {"screenshot"},
66+ "record missing file": {"record", "-fps", "30"},
67+ "empty file": {"screenshot", "-file", ""},
68+ "capture flag on gui": {"-file", "a.png"},
69+ "capture flag on outputs": {"outputs", "-output", "DP-1"},
70+ "fps on screenshot": {"screenshot", "-file", "a.png", "-fps", "30"},
71+ "duration on screenshot": {"screenshot", "-file", "a.png", "-duration", "1s"},
72+ "size on screenshot": {"screenshot", "-file", "a.png", "-size", "2x2"},
73+ "fps zero": {"record", "-file", "a", "-fps", "0"},
74+ "fps high": {"record", "-file", "a", "-fps", "121"},
75+ "fps text": {"record", "-file", "a", "-fps", "fast"},
76+ "size odd width": {"record", "-file", "a", "-size", "641x480"},
77+ "size odd height": {"record", "-file", "a", "-size", "640x481"},
78+ "size zero": {"record", "-file", "a", "-size", "0x0"},
79+ "size too large": {"record", "-file", "a", "-size", "16386x2"},
80+ "size frame too large": {"record", "-file", "a", "-size", "16384x16384"},
81+ "size missing height": {"record", "-file", "a", "-size", "640"},
82+ "size negative": {"record", "-file", "a", "-size", "-2x-2"},
83+ "size junk": {"record", "-file", "a", "-size", "axb"},
84+ "size signed": {"record", "-file", "a", "-size", "+2x2"},
85+ "duration negative": {"record", "-file", "a", "-duration", "-1s"},
86+ "duration bare number": {"record", "-file", "a", "-duration", "5"},
87+ "region empty parts": {"screenshot", "-file", "a", "-region", ",,x"},
88+ "region missing size": {"screenshot", "-file", "a", "-region", "1,2"},
89+ "region missing y": {"screenshot", "-file", "a", "-region", "1,10x10"},
90+ "region extra part": {"screenshot", "-file", "a", "-region", "1,2,3,10x10"},
91+ "region negative x": {"screenshot", "-file", "a", "-region", "-1,0,10x10"},
92+ "region negative y": {"screenshot", "-file", "a", "-region", "0,-1,10x10"},
93+ "region zero width": {"screenshot", "-file", "a", "-region", "0,0,0x10"},
94+ "region zero height": {"screenshot", "-file", "a", "-region", "0,0,10x0"},
95+ "region overflow": {"screenshot", "-file", "a", "-region", "0,0,99999999999x1"},
96+ "region sum overflow": {"screenshot", "-file", "a", "-region", "2147483647,0,1x1"},
97+ "region signed": {"screenshot", "-file", "a", "-region", "+1,0,10x10"},
98+ "region uppercase X": {"screenshot", "-file", "a", "-region", "0,0,10X10"},
99+ "region spaces": {"screenshot", "-file", "a", "-region", "0, 0, 10x10"},
100+ }
101+ for name, args := range cases {
102+ t.Run(name, func(t *testing.T) {
103+ var out bytes.Buffer
104+ got, err := cli.Parse(args, &out)
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, cli.ErrUsage)
107+ assert.NotErrorIs(t, err, flag.ErrHelp)
108+ assert.Equal(t, cli.Options{}, got)
109+ assert.Empty(t, out.String(), "errors are reported by the caller")
110+ })
111+ }
112+}
113+
114+func TestParseErrorNamesProblem(t *testing.T) {
115+ _, err := cli.Parse([]string{"record"}, nil)
116+ assert.ErrorContains(t, err, "-file is required")
117+ _, err = cli.Parse([]string{"snap"}, nil)
118+ assert.ErrorContains(t, err, `unknown command "snap"`)
119+ _, err = cli.Parse([]string{"screenshot", "-file", "a", "-region", "1,2"}, nil)
120+ assert.ErrorContains(t, err, "X,Y,WxH")
121+}
122+
123+func TestParseHelp(t *testing.T) {
124+ for _, args := range [][]string{{"-h"}, {"--help"}, {"-help"}, {"record", "--help"}, {"outputs", "-h"}} {
125+ var out bytes.Buffer
126+ got, err := cli.Parse(args, &out)
127+ assert.True(t, errors.Is(err, flag.ErrHelp), args)
128+ assert.Equal(t, cli.Options{}, got)
129+ assert.Equal(t, cli.Usage(), out.String())
130+ }
131+}
132+
133+func TestUsageMentionsEverything(t *testing.T) {
134+ u := cli.Usage()
135+ for _, s := range []string{"gui", "outputs", "screenshot", "record", "-output", "-region", "-file", "-fps", "-size", "-duration", "-debug"} {
136+ assert.Contains(t, u, s)
137+ }
138+}
A internal/core/service.go +190 -0
@@ -0,0 +1,190 @@
1+// Package core implements the capture workflow over ports only.
2+package core
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "math"
9+ "time"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// ErrInvalidSelection reports a selection rejected before any port is used.
15+var ErrInvalidSelection = errors.New("invalid capture selection")
16+
17+// Service runs one capture selection. It does not own the Source lifetime:
18+// the caller closes the Source after Run returns.
19+type Service struct {
20+ source ports.Source
21+ png ports.ScreenshotWriter
22+ video ports.VideoWriter
23+}
24+
25+// New wires the capture workflow to its ports.
26+func New(source ports.Source, png ports.ScreenshotWriter, video ports.VideoWriter) *Service {
27+ return &Service{source: source, png: png, video: video}
28+}
29+
30+// Run validates selection and then takes one screenshot or records silent
31+// fixed-rate video. Recording ends at the selection duration or when ctx is
32+// cancelled; both finalize the file through VideoWriter.Close. Captures and
33+// writes are sequential, so at most one is in flight.
34+func (s *Service) Run(ctx context.Context, selection ports.Selection) error {
35+ if err := validateSelection(selection); err != nil {
36+ return err
37+ }
38+ if err := ctx.Err(); err != nil {
39+ return err
40+ }
41+ if selection.Mode == ports.Screenshot {
42+ return s.screenshot(ctx, selection)
43+ }
44+ return s.record(ctx, selection)
45+}
46+
47+func (s *Service) screenshot(ctx context.Context, sel ports.Selection) error {
48+ frame, err := s.source.Capture(ctx, sel.Target)
49+ if err != nil {
50+ return fmt.Errorf("capture screenshot: %w", err)
51+ }
52+ if err := frame.Validate(); err != nil {
53+ return fmt.Errorf("captured frame: %w", err)
54+ }
55+ if err := s.png.Save(ctx, frame, sel.Path); err != nil {
56+ return fmt.Errorf("save screenshot: %w", err)
57+ }
58+ return nil
59+}
60+
61+func (s *Service) record(ctx context.Context, sel ports.Selection) error {
62+ runCtx := ctx
63+ if sel.Duration > 0 {
64+ var cancel context.CancelFunc
65+ runCtx, cancel = context.WithTimeout(ctx, sel.Duration)
66+ defer cancel()
67+ }
68+
69+ frame, err := s.source.Capture(runCtx, sel.Target)
70+ if err != nil {
71+ return fmt.Errorf("capture first frame: %w", err)
72+ }
73+ if err := frame.Validate(); err != nil {
74+ return fmt.Errorf("captured frame: %w", err)
75+ }
76+ if err := s.video.Start(runCtx, frame, sel.Path, sel.Video); err != nil {
77+ // Nothing was recorded: discard instead of finalizing.
78+ err = fmt.Errorf("start video: %w", err)
79+ if abortErr := s.video.Abort(); abortErr != nil {
80+ err = errors.Join(err, fmt.Errorf("abort video: %w", abortErr))
81+ }
82+ return err
83+ }
84+
85+ loopErr := s.recordLoop(runCtx, frame, sel)
86+ // Graceful finalize on every exit after Start; the writer bounds it.
87+ if closeErr := s.video.Close(); closeErr != nil {
88+ return errors.Join(loopErr, fmt.Errorf("finalize video: %w", closeErr))
89+ }
90+ return loopErr
91+}
92+
93+// recordLoop writes the first frame, then one frame per tick. It returns nil
94+// when the run context ends (duration elapsed or cancellation).
95+func (s *Service) recordLoop(ctx context.Context, first ports.Frame, sel ports.Selection) error {
96+ if err := s.video.Write(ctx, first); err != nil {
97+ return writeResult(ctx, err)
98+ }
99+ ticker := time.NewTicker(time.Second / time.Duration(sel.Video.FPS))
100+ defer ticker.Stop()
101+ for {
102+ select {
103+ case <-ctx.Done():
104+ return nil
105+ case <-ticker.C:
106+ }
107+ if ctx.Err() != nil {
108+ return nil
109+ }
110+ frame, err := s.source.Capture(ctx, sel.Target)
111+ if err != nil {
112+ if stopped(ctx, err) {
113+ return nil
114+ }
115+ return fmt.Errorf("capture frame: %w", err)
116+ }
117+ if err := frame.Validate(); err != nil {
118+ return fmt.Errorf("captured frame: %w", err)
119+ }
120+ if err := s.video.Write(ctx, frame); err != nil {
121+ return writeResult(ctx, err)
122+ }
123+ }
124+}
125+
126+func writeResult(ctx context.Context, err error) error {
127+ if stopped(ctx, err) {
128+ return nil
129+ }
130+ return fmt.Errorf("write frame: %w", err)
131+}
132+
133+// stopped reports whether err is only the consequence of the run ending.
134+func stopped(ctx context.Context, err error) bool {
135+ return ctx.Err() != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded))
136+}
137+
138+func validateSelection(sel ports.Selection) error {
139+ switch sel.Mode {
140+ case ports.Screenshot, ports.Record:
141+ default:
142+ return fmt.Errorf("%w: unknown mode %q", ErrInvalidSelection, sel.Mode)
143+ }
144+ if sel.Target.OutputID == 0 {
145+ return fmt.Errorf("%w: output id is zero", ErrInvalidSelection)
146+ }
147+ if err := validateRegion(sel.Target.Region); err != nil {
148+ return err
149+ }
150+ if sel.Path == "" {
151+ return fmt.Errorf("%w: empty path", ErrInvalidSelection)
152+ }
153+ if sel.Duration < 0 {
154+ return fmt.Errorf("%w: negative duration %s", ErrInvalidSelection, sel.Duration)
155+ }
156+ if sel.Mode == ports.Record {
157+ return validateVideo(sel.Video)
158+ }
159+ return nil
160+}
161+
162+// validateRegion accepts the zero region (full output) or a region with
163+// positive size and nonnegative origin that fits in int32.
164+func validateRegion(r ports.Region) error {
165+ if r == (ports.Region{}) {
166+ return nil
167+ }
168+ x, y, w, h := int64(r.X), int64(r.Y), int64(r.Width), int64(r.Height)
169+ if x < 0 || y < 0 || w < 1 || h < 1 ||
170+ x > math.MaxInt32 || y > math.MaxInt32 || w > math.MaxInt32 || h > math.MaxInt32 ||
171+ x+w > math.MaxInt32 || y+h > math.MaxInt32 {
172+ return fmt.Errorf("%w: %w: %+v", ErrInvalidSelection, ports.ErrInvalidRegion, r)
173+ }
174+ return nil
175+}
176+
177+func validateVideo(v ports.VideoSettings) error {
178+ if v.FPS < 1 || v.FPS > ports.MaxFPS {
179+ return fmt.Errorf("%w: fps %d outside 1..%d", ErrInvalidSelection, v.FPS, ports.MaxFPS)
180+ }
181+ if v.Width == 0 && v.Height == 0 {
182+ return nil
183+ }
184+ w, h := v.Width, v.Height
185+ if w < 1 || h < 1 || w > ports.MaxDimension || h > ports.MaxDimension || w%2 != 0 || h%2 != 0 ||
186+ w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
187+ return fmt.Errorf("%w: invalid output size %dx%d", ErrInvalidSelection, w, h)
188+ }
189+ return nil
190+}
@@ -0,0 +1,190 @@
1+// Package core implements the capture workflow over ports only.
2+package core
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "math"
9+ "time"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// ErrInvalidSelection reports a selection rejected before any port is used.
15+var ErrInvalidSelection = errors.New("invalid capture selection")
16+
17+// Service runs one capture selection. It does not own the Source lifetime:
18+// the caller closes the Source after Run returns.
19+type Service struct {
20+ source ports.Source
21+ png ports.ScreenshotWriter
22+ video ports.VideoWriter
23+}
24+
25+// New wires the capture workflow to its ports.
26+func New(source ports.Source, png ports.ScreenshotWriter, video ports.VideoWriter) *Service {
27+ return &Service{source: source, png: png, video: video}
28+}
29+
30+// Run validates selection and then takes one screenshot or records silent
31+// fixed-rate video. Recording ends at the selection duration or when ctx is
32+// cancelled; both finalize the file through VideoWriter.Close. Captures and
33+// writes are sequential, so at most one is in flight.
34+func (s *Service) Run(ctx context.Context, selection ports.Selection) error {
35+ if err := validateSelection(selection); err != nil {
36+ return err
37+ }
38+ if err := ctx.Err(); err != nil {
39+ return err
40+ }
41+ if selection.Mode == ports.Screenshot {
42+ return s.screenshot(ctx, selection)
43+ }
44+ return s.record(ctx, selection)
45+}
46+
47+func (s *Service) screenshot(ctx context.Context, sel ports.Selection) error {
48+ frame, err := s.source.Capture(ctx, sel.Target)
49+ if err != nil {
50+ return fmt.Errorf("capture screenshot: %w", err)
51+ }
52+ if err := frame.Validate(); err != nil {
53+ return fmt.Errorf("captured frame: %w", err)
54+ }
55+ if err := s.png.Save(ctx, frame, sel.Path); err != nil {
56+ return fmt.Errorf("save screenshot: %w", err)
57+ }
58+ return nil
59+}
60+
61+func (s *Service) record(ctx context.Context, sel ports.Selection) error {
62+ runCtx := ctx
63+ if sel.Duration > 0 {
64+ var cancel context.CancelFunc
65+ runCtx, cancel = context.WithTimeout(ctx, sel.Duration)
66+ defer cancel()
67+ }
68+
69+ frame, err := s.source.Capture(runCtx, sel.Target)
70+ if err != nil {
71+ return fmt.Errorf("capture first frame: %w", err)
72+ }
73+ if err := frame.Validate(); err != nil {
74+ return fmt.Errorf("captured frame: %w", err)
75+ }
76+ if err := s.video.Start(runCtx, frame, sel.Path, sel.Video); err != nil {
77+ // Nothing was recorded: discard instead of finalizing.
78+ err = fmt.Errorf("start video: %w", err)
79+ if abortErr := s.video.Abort(); abortErr != nil {
80+ err = errors.Join(err, fmt.Errorf("abort video: %w", abortErr))
81+ }
82+ return err
83+ }
84+
85+ loopErr := s.recordLoop(runCtx, frame, sel)
86+ // Graceful finalize on every exit after Start; the writer bounds it.
87+ if closeErr := s.video.Close(); closeErr != nil {
88+ return errors.Join(loopErr, fmt.Errorf("finalize video: %w", closeErr))
89+ }
90+ return loopErr
91+}
92+
93+// recordLoop writes the first frame, then one frame per tick. It returns nil
94+// when the run context ends (duration elapsed or cancellation).
95+func (s *Service) recordLoop(ctx context.Context, first ports.Frame, sel ports.Selection) error {
96+ if err := s.video.Write(ctx, first); err != nil {
97+ return writeResult(ctx, err)
98+ }
99+ ticker := time.NewTicker(time.Second / time.Duration(sel.Video.FPS))
100+ defer ticker.Stop()
101+ for {
102+ select {
103+ case <-ctx.Done():
104+ return nil
105+ case <-ticker.C:
106+ }
107+ if ctx.Err() != nil {
108+ return nil
109+ }
110+ frame, err := s.source.Capture(ctx, sel.Target)
111+ if err != nil {
112+ if stopped(ctx, err) {
113+ return nil
114+ }
115+ return fmt.Errorf("capture frame: %w", err)
116+ }
117+ if err := frame.Validate(); err != nil {
118+ return fmt.Errorf("captured frame: %w", err)
119+ }
120+ if err := s.video.Write(ctx, frame); err != nil {
121+ return writeResult(ctx, err)
122+ }
123+ }
124+}
125+
126+func writeResult(ctx context.Context, err error) error {
127+ if stopped(ctx, err) {
128+ return nil
129+ }
130+ return fmt.Errorf("write frame: %w", err)
131+}
132+
133+// stopped reports whether err is only the consequence of the run ending.
134+func stopped(ctx context.Context, err error) bool {
135+ return ctx.Err() != nil && (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded))
136+}
137+
138+func validateSelection(sel ports.Selection) error {
139+ switch sel.Mode {
140+ case ports.Screenshot, ports.Record:
141+ default:
142+ return fmt.Errorf("%w: unknown mode %q", ErrInvalidSelection, sel.Mode)
143+ }
144+ if sel.Target.OutputID == 0 {
145+ return fmt.Errorf("%w: output id is zero", ErrInvalidSelection)
146+ }
147+ if err := validateRegion(sel.Target.Region); err != nil {
148+ return err
149+ }
150+ if sel.Path == "" {
151+ return fmt.Errorf("%w: empty path", ErrInvalidSelection)
152+ }
153+ if sel.Duration < 0 {
154+ return fmt.Errorf("%w: negative duration %s", ErrInvalidSelection, sel.Duration)
155+ }
156+ if sel.Mode == ports.Record {
157+ return validateVideo(sel.Video)
158+ }
159+ return nil
160+}
161+
162+// validateRegion accepts the zero region (full output) or a region with
163+// positive size and nonnegative origin that fits in int32.
164+func validateRegion(r ports.Region) error {
165+ if r == (ports.Region{}) {
166+ return nil
167+ }
168+ x, y, w, h := int64(r.X), int64(r.Y), int64(r.Width), int64(r.Height)
169+ if x < 0 || y < 0 || w < 1 || h < 1 ||
170+ x > math.MaxInt32 || y > math.MaxInt32 || w > math.MaxInt32 || h > math.MaxInt32 ||
171+ x+w > math.MaxInt32 || y+h > math.MaxInt32 {
172+ return fmt.Errorf("%w: %w: %+v", ErrInvalidSelection, ports.ErrInvalidRegion, r)
173+ }
174+ return nil
175+}
176+
177+func validateVideo(v ports.VideoSettings) error {
178+ if v.FPS < 1 || v.FPS > ports.MaxFPS {
179+ return fmt.Errorf("%w: fps %d outside 1..%d", ErrInvalidSelection, v.FPS, ports.MaxFPS)
180+ }
181+ if v.Width == 0 && v.Height == 0 {
182+ return nil
183+ }
184+ w, h := v.Width, v.Height
185+ if w < 1 || h < 1 || w > ports.MaxDimension || h > ports.MaxDimension || w%2 != 0 || h%2 != 0 ||
186+ w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
187+ return fmt.Errorf("%w: invalid output size %dx%d", ErrInvalidSelection, w, h)
188+ }
189+ return nil
190+}
A internal/core/service_test.go +407 -0
@@ -0,0 +1,407 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/nefercap/internal/core"
15+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
16+ "github.com/bnema/nefercap/internal/ports"
17+)
18+
19+var (
20+ frame = ports.Frame{Pixels: make([]byte, 16), Width: 2, Height: 2, Stride: 8, Format: XRGB}
21+ target = ports.Target{OutputID: 7, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 4}}
22+)
23+
24+const XRGB = ports.XRGB8888
25+
26+type harness struct {
27+ src *portsmocks.MockSource
28+ png *portsmocks.MockScreenshotWriter
29+ vid *portsmocks.MockVideoWriter
30+ svc *core.Service
31+}
32+
33+func newHarness(t *testing.T) harness {
34+ t.Helper()
35+ h := harness{
36+ src: portsmocks.NewMockSource(t),
37+ png: portsmocks.NewMockScreenshotWriter(t),
38+ vid: portsmocks.NewMockVideoWriter(t),
39+ }
40+ h.svc = core.New(h.src, h.png, h.vid)
41+ return h
42+}
43+
44+func shot() ports.Selection {
45+ return ports.Selection{Mode: ports.Screenshot, Target: target, Path: "a.png"}
46+}
47+
48+func rec(d time.Duration) ports.Selection {
49+ return ports.Selection{
50+ Mode: ports.Record, Target: target, Path: "a.mkv", Duration: d,
51+ Video: ports.VideoSettings{FPS: 120},
52+ }
53+}
54+
55+func TestInvalidSelectionsMakeNoCalls(t *testing.T) {
56+ const maxI32 = 1<<31 - 1
57+ cases := map[string]func(*ports.Selection){
58+ "empty mode": func(s *ports.Selection) { s.Mode = "" },
59+ "unknown mode": func(s *ports.Selection) { s.Mode = "gif" },
60+ "zero output": func(s *ports.Selection) { s.Target.OutputID = 0 },
61+ "negative x": func(s *ports.Selection) { s.Target.Region = ports.Region{X: -1, Width: 1, Height: 1} },
62+ "negative y": func(s *ports.Selection) { s.Target.Region = ports.Region{Y: -1, Width: 1, Height: 1} },
63+ "zero width": func(s *ports.Selection) { s.Target.Region = ports.Region{X: 1, Height: 1} },
64+ "zero height": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: 1} },
65+ "negative width": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: -1, Height: 1} },
66+ "x overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{X: maxI32, Width: 1, Height: 1} },
67+ "width overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: maxI32 + 1, Height: 1} },
68+ "empty path": func(s *ports.Selection) { s.Path = "" },
69+ "negative duration": func(s *ports.Selection) { s.Duration = -time.Second },
70+ }
71+ for name, mutate := range cases {
72+ for _, base := range []ports.Selection{shot(), rec(time.Second)} {
73+ t.Run(string(base.Mode)+"/"+name, func(t *testing.T) {
74+ h := newHarness(t)
75+ sel := base
76+ mutate(&sel)
77+ err := h.svc.Run(context.Background(), sel)
78+ require.ErrorIs(t, err, core.ErrInvalidSelection)
79+ })
80+ }
81+ }
82+
83+ videos := map[string]ports.VideoSettings{
84+ "fps zero": {},
85+ "fps negative": {FPS: -1},
86+ "fps too high": {FPS: 121},
87+ "width only": {FPS: 30, Width: 640},
88+ "height only": {FPS: 30, Height: 480},
89+ "odd width": {FPS: 30, Width: 641, Height: 480},
90+ "odd height": {FPS: 30, Width: 640, Height: 481},
91+ "negative size": {FPS: 30, Width: -2, Height: -2},
92+ "oversized": {FPS: 30, Width: 16386, Height: 2},
93+ "frame too big": {FPS: 30, Width: 16384, Height: 16384},
94+ "oversized high": {FPS: 30, Width: 2, Height: 16386},
95+ }
96+ for name, v := range videos {
97+ t.Run("record/"+name, func(t *testing.T) {
98+ h := newHarness(t)
99+ sel := rec(0)
100+ sel.Video = v
101+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection)
102+ })
103+ }
104+}
105+
106+func TestRegionErrorIsInvalidRegion(t *testing.T) {
107+ h := newHarness(t)
108+ sel := shot()
109+ sel.Target.Region = ports.Region{X: -1, Width: 1, Height: 1}
110+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrInvalidRegion)
111+}
112+
113+func TestValidSelectionsAreAccepted(t *testing.T) {
114+ sizes := []ports.VideoSettings{{FPS: 1}, {FPS: 120, Width: 2, Height: 2}, {FPS: 30, Width: 16384, Height: 4096}}
115+ for _, v := range sizes {
116+ h := newHarness(t)
117+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
118+ h.vid.EXPECT().Start(mock.Anything, frame, "a.mkv", v).Return(errors.New("stop")).Once()
119+ h.vid.EXPECT().Abort().Return(nil).Once()
120+ sel := rec(0)
121+ sel.Video = v
122+ require.ErrorContains(t, h.svc.Run(context.Background(), sel), "stop")
123+ }
124+ // Zero region means full output.
125+ h := newHarness(t)
126+ full := ports.Target{OutputID: 1}
127+ h.src.EXPECT().Capture(mock.Anything, full).Return(frame, nil).Once()
128+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(nil).Once()
129+ sel := shot()
130+ sel.Target = full
131+ require.NoError(t, h.svc.Run(context.Background(), sel))
132+}
133+
134+func TestSelectionAllocations(t *testing.T) {
135+ sels := []ports.Selection{shot(), rec(time.Second)}
136+ h := newHarness(t)
137+ ctx, cancel := context.WithCancel(context.Background())
138+ cancel() // validation passes, then Run stops before any port call
139+ for _, sel := range sels {
140+ allocs := testing.AllocsPerRun(100, func() {
141+ if err := h.svc.Run(ctx, sel); !errors.Is(err, context.Canceled) {
142+ t.Fatalf("unexpected result %v", err)
143+ }
144+ })
145+ assert.Zero(t, allocs, sel.Mode)
146+ }
147+}
148+
149+func TestScreenshot(t *testing.T) {
150+ h := newHarness(t)
151+ ctx := context.Background()
152+ c := h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
153+ h.png.EXPECT().Save(ctx, frame, "a.png").Return(nil).Once().NotBefore(c)
154+ require.NoError(t, h.svc.Run(ctx, shot()))
155+}
156+
157+func TestScreenshotFailures(t *testing.T) {
158+ boom := errors.New("boom")
159+ t.Run("capture", func(t *testing.T) {
160+ h := newHarness(t)
161+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
162+ require.ErrorIs(t, h.svc.Run(context.Background(), shot()), boom)
163+ })
164+ t.Run("invalid frame", func(t *testing.T) {
165+ h := newHarness(t)
166+ bad := frame
167+ bad.Stride = 4
168+ h.src.EXPECT().Capture(mock.Anything, target).Return(bad, nil).Once()
169+ require.Error(t, h.svc.Run(context.Background(), shot()))
170+ })
171+ t.Run("save", func(t *testing.T) {
172+ h := newHarness(t)
173+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
174+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(errors.Join(boom, ports.ErrPathExists)).Once()
175+ err := h.svc.Run(context.Background(), shot())
176+ require.ErrorIs(t, err, boom)
177+ require.ErrorIs(t, err, ports.ErrPathExists)
178+ })
179+ t.Run("already cancelled", func(t *testing.T) {
180+ h := newHarness(t)
181+ ctx, cancel := context.WithCancel(context.Background())
182+ cancel()
183+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
184+ })
185+ t.Run("cancelled during capture", func(t *testing.T) {
186+ h := newHarness(t)
187+ ctx, cancel := context.WithCancel(context.Background())
188+ h.src.EXPECT().Capture(ctx, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
189+ cancel()
190+ return ports.Frame{}, ctx.Err()
191+ }).Once()
192+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
193+ })
194+ t.Run("cancelled during save", func(t *testing.T) {
195+ h := newHarness(t)
196+ ctx, cancel := context.WithCancel(context.Background())
197+ h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
198+ h.png.EXPECT().Save(ctx, frame, "a.png").RunAndReturn(func(ctx context.Context, _ ports.Frame, _ string) error {
199+ cancel()
200+ return ctx.Err()
201+ }).Once()
202+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
203+ })
204+}
205+
206+// recordOrder sets the mandatory prefix: Capture, Start, first Write.
207+func recordOrder(h harness, sel ports.Selection) (first *mock.Call) {
208+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
209+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(nil).Once().NotBefore(c)
210+ return s
211+}
212+
213+func TestRecordDuration(t *testing.T) {
214+ h := newHarness(t)
215+ sel := rec(60 * time.Millisecond)
216+ start := recordOrder(h, sel)
217+ var closed atomic.Bool
218+ var writes, inFlight atomic.Int32
219+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
220+ require.False(t, closed.Load(), "capture after close")
221+ require.Equal(t, int32(1), inFlight.Add(1), "concurrent capture")
222+ defer inFlight.Add(-1)
223+ return frame, nil
224+ }).Maybe()
225+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
226+ require.False(t, closed.Load(), "write after close")
227+ require.Equal(t, int32(1), inFlight.Add(1), "concurrent write")
228+ defer inFlight.Add(-1)
229+ writes.Add(1)
230+ return nil
231+ }).NotBefore(start)
232+ w.Maybe()
233+ h.vid.EXPECT().Close().RunAndReturn(func() error {
234+ closed.Store(true)
235+ return nil
236+ }).Once()
237+
238+ began := time.Now()
239+ require.NoError(t, h.svc.Run(context.Background(), sel))
240+ assert.GreaterOrEqual(t, time.Since(began), 60*time.Millisecond)
241+ assert.GreaterOrEqual(t, writes.Load(), int32(2))
242+ assert.True(t, closed.Load())
243+}
244+
245+func TestRecordFirstWriteCompletesBeforeTick(t *testing.T) {
246+ // 1 fps: only the first frame is written in a short run.
247+ h := newHarness(t)
248+ sel := rec(30 * time.Millisecond)
249+ sel.Video.FPS = 1
250+ start := recordOrder(h, sel)
251+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
252+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
253+ require.NoError(t, h.svc.Run(context.Background(), sel))
254+}
255+
256+func TestRecordCancelFinalizesGracefully(t *testing.T) {
257+ h := newHarness(t)
258+ sel := rec(0)
259+ ctx, cancel := context.WithCancel(context.Background())
260+ start := recordOrder(h, sel)
261+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
262+ var n atomic.Int32
263+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
264+ if n.Add(1) == 3 {
265+ cancel()
266+ }
267+ return nil
268+ }).NotBefore(start)
269+ w.Maybe()
270+ h.vid.EXPECT().Close().Return(nil).Once()
271+ require.NoError(t, h.svc.Run(ctx, sel))
272+ assert.GreaterOrEqual(t, n.Load(), int32(3))
273+}
274+
275+func TestRecordCancelInterruptsBlockedCapture(t *testing.T) {
276+ h := newHarness(t)
277+ sel := rec(0)
278+ ctx, cancel := context.WithCancel(context.Background())
279+ start := recordOrder(h, sel)
280+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
281+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
282+ cancel()
283+ <-ctx.Done()
284+ return ports.Frame{}, ctx.Err()
285+ }).Once()
286+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
287+ require.NoError(t, h.svc.Run(ctx, sel))
288+}
289+
290+func TestRecordCancelDuringWrite(t *testing.T) {
291+ h := newHarness(t)
292+ sel := rec(0)
293+ ctx, cancel := context.WithCancel(context.Background())
294+ start := recordOrder(h, sel)
295+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
296+ cancel()
297+ return ctx.Err()
298+ }).Once().NotBefore(start)
299+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
300+ require.NoError(t, h.svc.Run(ctx, sel))
301+}
302+
303+func TestRecordAlreadyCancelled(t *testing.T) {
304+ h := newHarness(t)
305+ ctx, cancel := context.WithCancel(context.Background())
306+ cancel()
307+ require.ErrorIs(t, h.svc.Run(ctx, rec(0)), context.Canceled)
308+}
309+
310+func TestRecordStartFailures(t *testing.T) {
311+ boom := errors.New("boom")
312+ abortErr := errors.New("abort failed")
313+ t.Run("first capture", func(t *testing.T) {
314+ h := newHarness(t)
315+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
316+ require.ErrorIs(t, h.svc.Run(context.Background(), rec(0)), boom)
317+ })
318+ t.Run("invalid first frame", func(t *testing.T) {
319+ h := newHarness(t)
320+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{Width: 2}, nil).Once()
321+ require.Error(t, h.svc.Run(context.Background(), rec(0)))
322+ })
323+ t.Run("start with abort failure", func(t *testing.T) {
324+ h := newHarness(t)
325+ sel := rec(0)
326+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
327+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(boom).Once().NotBefore(c)
328+ h.vid.EXPECT().Abort().Return(abortErr).Once().NotBefore(s)
329+ err := h.svc.Run(context.Background(), sel)
330+ require.ErrorIs(t, err, boom)
331+ require.ErrorIs(t, err, abortErr)
332+ })
333+ t.Run("path exists", func(t *testing.T) {
334+ h := newHarness(t)
335+ sel := rec(0)
336+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
337+ h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(ports.ErrPathExists).Once()
338+ h.vid.EXPECT().Abort().Return(nil).Once()
339+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrPathExists)
340+ })
341+}
342+
343+func TestRecordRuntimeFailures(t *testing.T) {
344+ boom := errors.New("boom")
345+ closeErr := errors.New("close failed")
346+
347+ t.Run("first write", func(t *testing.T) {
348+ h := newHarness(t)
349+ sel := rec(0)
350+ start := recordOrder(h, sel)
351+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(boom).Once().NotBefore(start)
352+ h.vid.EXPECT().Close().Return(closeErr).Once().NotBefore(w)
353+ err := h.svc.Run(context.Background(), sel)
354+ require.ErrorIs(t, err, boom)
355+ require.ErrorIs(t, err, closeErr)
356+ })
357+ t.Run("later capture", func(t *testing.T) {
358+ h := newHarness(t)
359+ sel := rec(0)
360+ start := recordOrder(h, sel)
361+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
362+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
363+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
364+ err := h.svc.Run(context.Background(), sel)
365+ require.ErrorIs(t, err, boom)
366+ assert.NotErrorIs(t, err, closeErr)
367+ })
368+ t.Run("later invalid frame", func(t *testing.T) {
369+ h := newHarness(t)
370+ sel := rec(0)
371+ start := recordOrder(h, sel)
372+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
373+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, nil).Once()
374+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
375+ require.Error(t, h.svc.Run(context.Background(), sel))
376+ })
377+ t.Run("later capture and close", func(t *testing.T) {
378+ h := newHarness(t)
379+ sel := rec(0)
380+ start := recordOrder(h, sel)
381+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
382+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
383+ h.vid.EXPECT().Close().Return(closeErr).Once().NotBefore(w)
384+ err := h.svc.Run(context.Background(), sel)
385+ require.ErrorIs(t, err, boom)
386+ require.ErrorIs(t, err, closeErr)
387+ })
388+ t.Run("geometry change", func(t *testing.T) {
389+ h := newHarness(t)
390+ sel := rec(0)
391+ start := recordOrder(h, sel)
392+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
393+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
394+ h.vid.EXPECT().Write(mock.Anything, frame).Return(ports.ErrGeometryChanged).Once().NotBefore(first)
395+ h.vid.EXPECT().Close().Return(nil).Once()
396+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrGeometryChanged)
397+ })
398+ t.Run("close only", func(t *testing.T) {
399+ h := newHarness(t)
400+ sel := rec(20 * time.Millisecond)
401+ start := recordOrder(h, sel)
402+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
403+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
404+ h.vid.EXPECT().Close().Return(closeErr).Once()
405+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), closeErr)
406+ })
407+}
@@ -0,0 +1,407 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/nefercap/internal/core"
15+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
16+ "github.com/bnema/nefercap/internal/ports"
17+)
18+
19+var (
20+ frame = ports.Frame{Pixels: make([]byte, 16), Width: 2, Height: 2, Stride: 8, Format: XRGB}
21+ target = ports.Target{OutputID: 7, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 4}}
22+)
23+
24+const XRGB = ports.XRGB8888
25+
26+type harness struct {
27+ src *portsmocks.MockSource
28+ png *portsmocks.MockScreenshotWriter
29+ vid *portsmocks.MockVideoWriter
30+ svc *core.Service
31+}
32+
33+func newHarness(t *testing.T) harness {
34+ t.Helper()
35+ h := harness{
36+ src: portsmocks.NewMockSource(t),
37+ png: portsmocks.NewMockScreenshotWriter(t),
38+ vid: portsmocks.NewMockVideoWriter(t),
39+ }
40+ h.svc = core.New(h.src, h.png, h.vid)
41+ return h
42+}
43+
44+func shot() ports.Selection {
45+ return ports.Selection{Mode: ports.Screenshot, Target: target, Path: "a.png"}
46+}
47+
48+func rec(d time.Duration) ports.Selection {
49+ return ports.Selection{
50+ Mode: ports.Record, Target: target, Path: "a.mkv", Duration: d,
51+ Video: ports.VideoSettings{FPS: 120},
52+ }
53+}
54+
55+func TestInvalidSelectionsMakeNoCalls(t *testing.T) {
56+ const maxI32 = 1<<31 - 1
57+ cases := map[string]func(*ports.Selection){
58+ "empty mode": func(s *ports.Selection) { s.Mode = "" },
59+ "unknown mode": func(s *ports.Selection) { s.Mode = "gif" },
60+ "zero output": func(s *ports.Selection) { s.Target.OutputID = 0 },
61+ "negative x": func(s *ports.Selection) { s.Target.Region = ports.Region{X: -1, Width: 1, Height: 1} },
62+ "negative y": func(s *ports.Selection) { s.Target.Region = ports.Region{Y: -1, Width: 1, Height: 1} },
63+ "zero width": func(s *ports.Selection) { s.Target.Region = ports.Region{X: 1, Height: 1} },
64+ "zero height": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: 1} },
65+ "negative width": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: -1, Height: 1} },
66+ "x overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{X: maxI32, Width: 1, Height: 1} },
67+ "width overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: maxI32 + 1, Height: 1} },
68+ "empty path": func(s *ports.Selection) { s.Path = "" },
69+ "negative duration": func(s *ports.Selection) { s.Duration = -time.Second },
70+ }
71+ for name, mutate := range cases {
72+ for _, base := range []ports.Selection{shot(), rec(time.Second)} {
73+ t.Run(string(base.Mode)+"/"+name, func(t *testing.T) {
74+ h := newHarness(t)
75+ sel := base
76+ mutate(&sel)
77+ err := h.svc.Run(context.Background(), sel)
78+ require.ErrorIs(t, err, core.ErrInvalidSelection)
79+ })
80+ }
81+ }
82+
83+ videos := map[string]ports.VideoSettings{
84+ "fps zero": {},
85+ "fps negative": {FPS: -1},
86+ "fps too high": {FPS: 121},
87+ "width only": {FPS: 30, Width: 640},
88+ "height only": {FPS: 30, Height: 480},
89+ "odd width": {FPS: 30, Width: 641, Height: 480},
90+ "odd height": {FPS: 30, Width: 640, Height: 481},
91+ "negative size": {FPS: 30, Width: -2, Height: -2},
92+ "oversized": {FPS: 30, Width: 16386, Height: 2},
93+ "frame too big": {FPS: 30, Width: 16384, Height: 16384},
94+ "oversized high": {FPS: 30, Width: 2, Height: 16386},
95+ }
96+ for name, v := range videos {
97+ t.Run("record/"+name, func(t *testing.T) {
98+ h := newHarness(t)
99+ sel := rec(0)
100+ sel.Video = v
101+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection)
102+ })
103+ }
104+}
105+
106+func TestRegionErrorIsInvalidRegion(t *testing.T) {
107+ h := newHarness(t)
108+ sel := shot()
109+ sel.Target.Region = ports.Region{X: -1, Width: 1, Height: 1}
110+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrInvalidRegion)
111+}
112+
113+func TestValidSelectionsAreAccepted(t *testing.T) {
114+ sizes := []ports.VideoSettings{{FPS: 1}, {FPS: 120, Width: 2, Height: 2}, {FPS: 30, Width: 16384, Height: 4096}}
115+ for _, v := range sizes {
116+ h := newHarness(t)
117+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
118+ h.vid.EXPECT().Start(mock.Anything, frame, "a.mkv", v).Return(errors.New("stop")).Once()
119+ h.vid.EXPECT().Abort().Return(nil).Once()
120+ sel := rec(0)
121+ sel.Video = v
122+ require.ErrorContains(t, h.svc.Run(context.Background(), sel), "stop")
123+ }
124+ // Zero region means full output.
125+ h := newHarness(t)
126+ full := ports.Target{OutputID: 1}
127+ h.src.EXPECT().Capture(mock.Anything, full).Return(frame, nil).Once()
128+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(nil).Once()
129+ sel := shot()
130+ sel.Target = full
131+ require.NoError(t, h.svc.Run(context.Background(), sel))
132+}
133+
134+func TestSelectionAllocations(t *testing.T) {
135+ sels := []ports.Selection{shot(), rec(time.Second)}
136+ h := newHarness(t)
137+ ctx, cancel := context.WithCancel(context.Background())
138+ cancel() // validation passes, then Run stops before any port call
139+ for _, sel := range sels {
140+ allocs := testing.AllocsPerRun(100, func() {
141+ if err := h.svc.Run(ctx, sel); !errors.Is(err, context.Canceled) {
142+ t.Fatalf("unexpected result %v", err)
143+ }
144+ })
145+ assert.Zero(t, allocs, sel.Mode)
146+ }
147+}
148+
149+func TestScreenshot(t *testing.T) {
150+ h := newHarness(t)
151+ ctx := context.Background()
152+ c := h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
153+ h.png.EXPECT().Save(ctx, frame, "a.png").Return(nil).Once().NotBefore(c)
154+ require.NoError(t, h.svc.Run(ctx, shot()))
155+}
156+
157+func TestScreenshotFailures(t *testing.T) {
158+ boom := errors.New("boom")
159+ t.Run("capture", func(t *testing.T) {
160+ h := newHarness(t)
161+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
162+ require.ErrorIs(t, h.svc.Run(context.Background(), shot()), boom)
163+ })
164+ t.Run("invalid frame", func(t *testing.T) {
165+ h := newHarness(t)
166+ bad := frame
167+ bad.Stride = 4
168+ h.src.EXPECT().Capture(mock.Anything, target).Return(bad, nil).Once()
169+ require.Error(t, h.svc.Run(context.Background(), shot()))
170+ })
171+ t.Run("save", func(t *testing.T) {
172+ h := newHarness(t)
173+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
174+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(errors.Join(boom, ports.ErrPathExists)).Once()
175+ err := h.svc.Run(context.Background(), shot())
176+ require.ErrorIs(t, err, boom)
177+ require.ErrorIs(t, err, ports.ErrPathExists)
178+ })
179+ t.Run("already cancelled", func(t *testing.T) {
180+ h := newHarness(t)
181+ ctx, cancel := context.WithCancel(context.Background())
182+ cancel()
183+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
184+ })
185+ t.Run("cancelled during capture", func(t *testing.T) {
186+ h := newHarness(t)
187+ ctx, cancel := context.WithCancel(context.Background())
188+ h.src.EXPECT().Capture(ctx, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
189+ cancel()
190+ return ports.Frame{}, ctx.Err()
191+ }).Once()
192+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
193+ })
194+ t.Run("cancelled during save", func(t *testing.T) {
195+ h := newHarness(t)
196+ ctx, cancel := context.WithCancel(context.Background())
197+ h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
198+ h.png.EXPECT().Save(ctx, frame, "a.png").RunAndReturn(func(ctx context.Context, _ ports.Frame, _ string) error {
199+ cancel()
200+ return ctx.Err()
201+ }).Once()
202+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
203+ })
204+}
205+
206+// recordOrder sets the mandatory prefix: Capture, Start, first Write.
207+func recordOrder(h harness, sel ports.Selection) (first *mock.Call) {
208+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
209+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(nil).Once().NotBefore(c)
210+ return s
211+}
212+
213+func TestRecordDuration(t *testing.T) {
214+ h := newHarness(t)
215+ sel := rec(60 * time.Millisecond)
216+ start := recordOrder(h, sel)
217+ var closed atomic.Bool
218+ var writes, inFlight atomic.Int32
219+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
220+ require.False(t, closed.Load(), "capture after close")
221+ require.Equal(t, int32(1), inFlight.Add(1), "concurrent capture")
222+ defer inFlight.Add(-1)
223+ return frame, nil
224+ }).Maybe()
225+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
226+ require.False(t, closed.Load(), "write after close")
227+ require.Equal(t, int32(1), inFlight.Add(1), "concurrent write")
228+ defer inFlight.Add(-1)
229+ writes.Add(1)
230+ return nil
231+ }).NotBefore(start)
232+ w.Maybe()
233+ h.vid.EXPECT().Close().RunAndReturn(func() error {
234+ closed.Store(true)
235+ return nil
236+ }).Once()
237+
238+ began := time.Now()
239+ require.NoError(t, h.svc.Run(context.Background(), sel))
240+ assert.GreaterOrEqual(t, time.Since(began), 60*time.Millisecond)
241+ assert.GreaterOrEqual(t, writes.Load(), int32(2))
242+ assert.True(t, closed.Load())
243+}
244+
245+func TestRecordFirstWriteCompletesBeforeTick(t *testing.T) {
246+ // 1 fps: only the first frame is written in a short run.
247+ h := newHarness(t)
248+ sel := rec(30 * time.Millisecond)
249+ sel.Video.FPS = 1
250+ start := recordOrder(h, sel)
251+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
252+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
253+ require.NoError(t, h.svc.Run(context.Background(), sel))
254+}
255+
256+func TestRecordCancelFinalizesGracefully(t *testing.T) {
257+ h := newHarness(t)
258+ sel := rec(0)
259+ ctx, cancel := context.WithCancel(context.Background())
260+ start := recordOrder(h, sel)
261+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
262+ var n atomic.Int32
263+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
264+ if n.Add(1) == 3 {
265+ cancel()
266+ }
267+ return nil
268+ }).NotBefore(start)
269+ w.Maybe()
270+ h.vid.EXPECT().Close().Return(nil).Once()
271+ require.NoError(t, h.svc.Run(ctx, sel))
272+ assert.GreaterOrEqual(t, n.Load(), int32(3))
273+}
274+
275+func TestRecordCancelInterruptsBlockedCapture(t *testing.T) {
276+ h := newHarness(t)
277+ sel := rec(0)
278+ ctx, cancel := context.WithCancel(context.Background())
279+ start := recordOrder(h, sel)
280+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
281+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
282+ cancel()
283+ <-ctx.Done()
284+ return ports.Frame{}, ctx.Err()
285+ }).Once()
286+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
287+ require.NoError(t, h.svc.Run(ctx, sel))
288+}
289+
290+func TestRecordCancelDuringWrite(t *testing.T) {
291+ h := newHarness(t)
292+ sel := rec(0)
293+ ctx, cancel := context.WithCancel(context.Background())
294+ start := recordOrder(h, sel)
295+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
296+ cancel()
297+ return ctx.Err()
298+ }).Once().NotBefore(start)
299+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
300+ require.NoError(t, h.svc.Run(ctx, sel))
301+}
302+
303+func TestRecordAlreadyCancelled(t *testing.T) {
304+ h := newHarness(t)
305+ ctx, cancel := context.WithCancel(context.Background())
306+ cancel()
307+ require.ErrorIs(t, h.svc.Run(ctx, rec(0)), context.Canceled)
308+}
309+
310+func TestRecordStartFailures(t *testing.T) {
311+ boom := errors.New("boom")
312+ abortErr := errors.New("abort failed")
313+ t.Run("first capture", func(t *testing.T) {
314+ h := newHarness(t)
315+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
316+ require.ErrorIs(t, h.svc.Run(context.Background(), rec(0)), boom)
317+ })
318+ t.Run("invalid first frame", func(t *testing.T) {
319+ h := newHarness(t)
320+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{Width: 2}, nil).Once()
321+ require.Error(t, h.svc.Run(context.Background(), rec(0)))
322+ })
323+ t.Run("start with abort failure", func(t *testing.T) {
324+ h := newHarness(t)
325+ sel := rec(0)
326+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
327+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(boom).Once().NotBefore(c)
328+ h.vid.EXPECT().Abort().Return(abortErr).Once().NotBefore(s)
329+ err := h.svc.Run(context.Background(), sel)
330+ require.ErrorIs(t, err, boom)
331+ require.ErrorIs(t, err, abortErr)
332+ })
333+ t.Run("path exists", func(t *testing.T) {
334+ h := newHarness(t)
335+ sel := rec(0)
336+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
337+ h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(ports.ErrPathExists).Once()
338+ h.vid.EXPECT().Abort().Return(nil).Once()
339+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrPathExists)
340+ })
341+}
342+
343+func TestRecordRuntimeFailures(t *testing.T) {
344+ boom := errors.New("boom")
345+ closeErr := errors.New("close failed")
346+
347+ t.Run("first write", func(t *testing.T) {
348+ h := newHarness(t)
349+ sel := rec(0)
350+ start := recordOrder(h, sel)
351+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(boom).Once().NotBefore(start)
352+ h.vid.EXPECT().Close().Return(closeErr).Once().NotBefore(w)
353+ err := h.svc.Run(context.Background(), sel)
354+ require.ErrorIs(t, err, boom)
355+ require.ErrorIs(t, err, closeErr)
356+ })
357+ t.Run("later capture", func(t *testing.T) {
358+ h := newHarness(t)
359+ sel := rec(0)
360+ start := recordOrder(h, sel)
361+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
362+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
363+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
364+ err := h.svc.Run(context.Background(), sel)
365+ require.ErrorIs(t, err, boom)
366+ assert.NotErrorIs(t, err, closeErr)
367+ })
368+ t.Run("later invalid frame", func(t *testing.T) {
369+ h := newHarness(t)
370+ sel := rec(0)
371+ start := recordOrder(h, sel)
372+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
373+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, nil).Once()
374+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
375+ require.Error(t, h.svc.Run(context.Background(), sel))
376+ })
377+ t.Run("later capture and close", func(t *testing.T) {
378+ h := newHarness(t)
379+ sel := rec(0)
380+ start := recordOrder(h, sel)
381+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
382+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
383+ h.vid.EXPECT().Close().Return(closeErr).Once().NotBefore(w)
384+ err := h.svc.Run(context.Background(), sel)
385+ require.ErrorIs(t, err, boom)
386+ require.ErrorIs(t, err, closeErr)
387+ })
388+ t.Run("geometry change", func(t *testing.T) {
389+ h := newHarness(t)
390+ sel := rec(0)
391+ start := recordOrder(h, sel)
392+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
393+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
394+ h.vid.EXPECT().Write(mock.Anything, frame).Return(ports.ErrGeometryChanged).Once().NotBefore(first)
395+ h.vid.EXPECT().Close().Return(nil).Once()
396+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrGeometryChanged)
397+ })
398+ t.Run("close only", func(t *testing.T) {
399+ h := newHarness(t)
400+ sel := rec(20 * time.Millisecond)
401+ start := recordOrder(h, sel)
402+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
403+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
404+ h.vid.EXPECT().Close().Return(closeErr).Once()
405+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), closeErr)
406+ })
407+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close