detached 9823f75

feat(screencast): take screenshots through nefercap

commit
9823f75c5d1263905d32cdcaef56a204cb0957d3
author
brice · · 2026-10-14 20:29 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
f6fd269
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +145 -0
M internal/adapters/screencast/harness_test.go +3 -0
@@ -89,6 +89,7 @@ type svcHarness struct {
8989 cmds chan ports.CastCommand
9090 peers *peers
9191 core *mockvideoCore
92+ run *mockrunner
9293 log *logBuf
9394 done chan struct{}
9495 }
@@ -136,6 +137,8 @@ func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness {
136137 return h.peers.exe.Load().(string), pid, nil
137138 }).Maybe()
138139 h.svc.peers = res
140+ h.run = newMockrunner(t)
141+ h.svc.runner = h.run
139142
140143 pwOpen := newMockpipewireOpener(t)
141144 if o.noPW {
@@ -89,6 +89,7 @@ type svcHarness struct {
89 cmds chan ports.CastCommand89 cmds chan ports.CastCommand
90 peers *peers90 peers *peers
91 core *mockvideoCore91 core *mockvideoCore
92+ run *mockrunner
92 log *logBuf93 log *logBuf
93 done chan struct{}94 done chan struct{}
94 }95 }
@@ -136,6 +137,8 @@ func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness {
136 return h.peers.exe.Load().(string), pid, nil137 return h.peers.exe.Load().(string), pid, nil
137 }).Maybe()138 }).Maybe()
138 h.svc.peers = res139 h.svc.peers = res
140+ h.run = newMockrunner(t)
141+ h.svc.runner = h.run
139 142
140 pwOpen := newMockpipewireOpener(t)143 pwOpen := newMockpipewireOpener(t)
141 if o.noPW {144 if o.noPW {
A internal/adapters/screencast/screenshot_test.go +142 -0
@@ -0,0 +1,142 @@
1+package screencast
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/zerobus"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+// Screenshot: nefercap on the focused output; the reply carries the file.
16+func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) {
17+ rt := t.TempDir()
18+ t.Setenv("XDG_RUNTIME_DIR", rt)
19+ h, c := ready(t, harnessOpts{})
20+ got := make(chan []string, 1)
21+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
22+ got <- slices.Clone(args)
23+ return nil
24+ }).Once()
25+ name, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) {
26+ e.Bool(true)
27+ e.Bool(true)
28+ e.Str("ignored")
29+ })
30+ if name != "" {
31+ t.Fatal(name)
32+ }
33+ r := m.Body()
34+ ok, file := r.Bool(), strings.Clone(r.Str())
35+ args := <-got
36+ if !ok || !strings.HasPrefix(file, filepath.Join(rt, "neferwl", "screenshot-")) || !strings.HasSuffix(file, ".png") {
37+ t.Fatalf("reply %v %q", ok, file)
38+ }
39+ want := []string{"screenshot", "-output", "HEADLESS-1", "-file", file}
40+ if !slices.Equal(args, want) {
41+ t.Fatalf("args %v, want %v", args, want)
42+ }
43+ if fi, err := os.Stat(filepath.Join(rt, "neferwl")); err != nil || fi.Mode().Perm() != 0o700 {
44+ t.Fatalf("directory %v %v", fi, err)
45+ }
46+}
47+
48+// ScreenshotArea: the output containing the corner, with a clipped,
49+// output-local region; outside every output is InvalidArgs.
50+func TestScreenshotAreaMapsToRegion(t *testing.T) {
51+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
52+ h, c := ready(t, harnessOpts{})
53+ got := make(chan []string, 1)
54+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
55+ got <- slices.Clone(args)
56+ return nil
57+ }).Once()
58+ area := func(x, y, w, hh int32) func(*zerobus.Encoder) {
59+ return func(e *zerobus.Encoder) {
60+ e.Int32(x)
61+ e.Int32(y)
62+ e.Int32(w)
63+ e.Int32(hh)
64+ e.Bool(false)
65+ e.Str("f")
66+ }
67+ }
68+ // HEADLESS-2 sits at x=64, 32x32: the area starts at (70,4), wider than the rest of the output.
69+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(70, 4, 100, 10)); name != "" {
70+ t.Fatal(name)
71+ }
72+ args := <-got
73+ if args[1] != "-output" || args[2] != "HEADLESS-2" || args[3] != "-region" || args[4] != "6,4,26x10" {
74+ t.Fatalf("args %v", args)
75+ }
76+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(500, 4, 10, 10)); name != errInvalidArgs {
77+ t.Fatalf("outside: %q", name)
78+ }
79+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(0, 0, 0, 10)); name != errInvalidArgs {
80+ t.Fatalf("empty: %q", name)
81+ }
82+}
83+
84+// InteractiveScreenshot: nefercap shot, answered with a file:// uri. A second
85+// shot while one runs is LimitsExceeded; a failure is Failed and the busy
86+// flag clears.
87+func TestInteractiveScreenshotBusyAndFailure(t *testing.T) {
88+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
89+ h, c := ready(t, harnessOpts{})
90+ release := make(chan error)
91+ got := make(chan []string, 2)
92+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
93+ got <- slices.Clone(args)
94+ return <-release
95+ }).Twice()
96+
97+ c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
98+ first, err := c.Send()
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ args := <-got
103+ if len(args) != 3 || args[0] != "shot" || args[1] != "-file" {
104+ t.Fatalf("args %v", args)
105+ }
106+ other := h.client()
107+ if name, _ := callErr(other, nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil); name != errLimits {
108+ t.Fatalf("second shot: %q", name)
109+ }
110+ release <- nil
111+ m, err := c.ReadMessage()
112+ if err != nil || m.ReplySerial != first {
113+ t.Fatalf("reply %v %v", m, err)
114+ }
115+ r := m.Body()
116+ if ok, uri := r.Bool(), r.Str(); !ok || uri != "file://"+args[2] {
117+ t.Fatalf("reply %v %q", ok, uri)
118+ }
119+
120+ // nefercap fails (or is missing): Failed, and the next shot is allowed.
121+ c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
122+ if _, err := c.Send(); err != nil {
123+ t.Fatal(err)
124+ }
125+ <-got
126+ release <- os.ErrNotExist
127+ m, err = c.ReadMessage()
128+ if err != nil || m.ErrorName != errFailed {
129+ t.Fatalf("failure reply %+v %v", m, err)
130+ }
131+}
132+
133+// What the dialog offers beyond the supported calls is NotSupported.
134+func TestScreenshotUnsupportedMethods(t *testing.T) {
135+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
136+ _, c := ready(t, harnessOpts{})
137+ for _, member := range []string{"SelectArea", "ScreenshotWindow", "PickColor", "FlashArea"} {
138+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, "", nil); name != errNotSupported && name != errInvalidArgs {
139+ t.Errorf("%s: %q", member, name)
140+ }
141+ }
142+}
@@ -0,0 +1,142 @@
1+package screencast
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/zerobus"
12+ "github.com/stretchr/testify/mock"
13+)
14+
15+// Screenshot: nefercap on the focused output; the reply carries the file.
16+func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) {
17+ rt := t.TempDir()
18+ t.Setenv("XDG_RUNTIME_DIR", rt)
19+ h, c := ready(t, harnessOpts{})
20+ got := make(chan []string, 1)
21+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
22+ got <- slices.Clone(args)
23+ return nil
24+ }).Once()
25+ name, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) {
26+ e.Bool(true)
27+ e.Bool(true)
28+ e.Str("ignored")
29+ })
30+ if name != "" {
31+ t.Fatal(name)
32+ }
33+ r := m.Body()
34+ ok, file := r.Bool(), strings.Clone(r.Str())
35+ args := <-got
36+ if !ok || !strings.HasPrefix(file, filepath.Join(rt, "neferwl", "screenshot-")) || !strings.HasSuffix(file, ".png") {
37+ t.Fatalf("reply %v %q", ok, file)
38+ }
39+ want := []string{"screenshot", "-output", "HEADLESS-1", "-file", file}
40+ if !slices.Equal(args, want) {
41+ t.Fatalf("args %v, want %v", args, want)
42+ }
43+ if fi, err := os.Stat(filepath.Join(rt, "neferwl")); err != nil || fi.Mode().Perm() != 0o700 {
44+ t.Fatalf("directory %v %v", fi, err)
45+ }
46+}
47+
48+// ScreenshotArea: the output containing the corner, with a clipped,
49+// output-local region; outside every output is InvalidArgs.
50+func TestScreenshotAreaMapsToRegion(t *testing.T) {
51+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
52+ h, c := ready(t, harnessOpts{})
53+ got := make(chan []string, 1)
54+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
55+ got <- slices.Clone(args)
56+ return nil
57+ }).Once()
58+ area := func(x, y, w, hh int32) func(*zerobus.Encoder) {
59+ return func(e *zerobus.Encoder) {
60+ e.Int32(x)
61+ e.Int32(y)
62+ e.Int32(w)
63+ e.Int32(hh)
64+ e.Bool(false)
65+ e.Str("f")
66+ }
67+ }
68+ // HEADLESS-2 sits at x=64, 32x32: the area starts at (70,4), wider than the rest of the output.
69+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(70, 4, 100, 10)); name != "" {
70+ t.Fatal(name)
71+ }
72+ args := <-got
73+ if args[1] != "-output" || args[2] != "HEADLESS-2" || args[3] != "-region" || args[4] != "6,4,26x10" {
74+ t.Fatalf("args %v", args)
75+ }
76+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(500, 4, 10, 10)); name != errInvalidArgs {
77+ t.Fatalf("outside: %q", name)
78+ }
79+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(0, 0, 0, 10)); name != errInvalidArgs {
80+ t.Fatalf("empty: %q", name)
81+ }
82+}
83+
84+// InteractiveScreenshot: nefercap shot, answered with a file:// uri. A second
85+// shot while one runs is LimitsExceeded; a failure is Failed and the busy
86+// flag clears.
87+func TestInteractiveScreenshotBusyAndFailure(t *testing.T) {
88+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
89+ h, c := ready(t, harnessOpts{})
90+ release := make(chan error)
91+ got := make(chan []string, 2)
92+ h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
93+ got <- slices.Clone(args)
94+ return <-release
95+ }).Twice()
96+
97+ c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
98+ first, err := c.Send()
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ args := <-got
103+ if len(args) != 3 || args[0] != "shot" || args[1] != "-file" {
104+ t.Fatalf("args %v", args)
105+ }
106+ other := h.client()
107+ if name, _ := callErr(other, nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil); name != errLimits {
108+ t.Fatalf("second shot: %q", name)
109+ }
110+ release <- nil
111+ m, err := c.ReadMessage()
112+ if err != nil || m.ReplySerial != first {
113+ t.Fatalf("reply %v %v", m, err)
114+ }
115+ r := m.Body()
116+ if ok, uri := r.Bool(), r.Str(); !ok || uri != "file://"+args[2] {
117+ t.Fatalf("reply %v %q", ok, uri)
118+ }
119+
120+ // nefercap fails (or is missing): Failed, and the next shot is allowed.
121+ c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
122+ if _, err := c.Send(); err != nil {
123+ t.Fatal(err)
124+ }
125+ <-got
126+ release <- os.ErrNotExist
127+ m, err = c.ReadMessage()
128+ if err != nil || m.ErrorName != errFailed {
129+ t.Fatalf("failure reply %+v %v", m, err)
130+ }
131+}
132+
133+// What the dialog offers beyond the supported calls is NotSupported.
134+func TestScreenshotUnsupportedMethods(t *testing.T) {
135+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
136+ _, c := ready(t, harnessOpts{})
137+ for _, member := range []string{"SelectArea", "ScreenshotWindow", "PickColor", "FlashArea"} {
138+ if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, "", nil); name != errNotSupported && name != errInvalidArgs {
139+ t.Errorf("%s: %q", member, name)
140+ }
141+ }
142+}

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 25

Tags 10

↑↓ moveenter openesc close