1package visual
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "testing"
9 "time"
10
11 "github.com/bnema/neferafk/internal/adapters/visualproc"
12 "github.com/bnema/neferafk/internal/ports"
13 "github.com/bnema/nefergui"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16)
17
18type harness struct {
19 cmdW *io.PipeWriter
20 events *io.PipeReader
21 done chan error
22 cfgs chan nefergui.LockConfig
23 l *mockworkerLauncher
24 lock *mocklockRunner
25 fade *mockfadeRunner
26}
27
28// newHarness runs a session over real pipes; RunLock is a mock that publishes
29// its config and returns nil only once Unlock fires (or ctx ends).
30func newHarness(t *testing.T) *harness { return newHarnessRun(t, nil) }
31
32// newHarnessRun is newHarness with an optional replacement RunLock body.
33func newHarnessRun(t *testing.T, runLock func(context.Context, nefergui.LockConfig) error) *harness {
34 t.Helper()
35 cmdR, cmdW := io.Pipe()
36 evR, evW := io.Pipe()
37 h := &harness{cmdW: cmdW, events: evR, done: make(chan error, 1), cfgs: make(chan nefergui.LockConfig, 1),
38 l: newMockworkerLauncher(t), lock: newMocklockRunner(t), fade: newMockfadeRunner(t)}
39 if runLock == nil {
40 runLock = func(ctx context.Context, cfg nefergui.LockConfig) error {
41 h.cfgs <- cfg
42 select {
43 case <-cfg.Unlock:
44 return nil
45 case <-ctx.Done():
46 return ctx.Err()
47 }
48 }
49 }
50 h.lock.EXPECT().RunLock(mock.Anything, mock.Anything).RunAndReturn(runLock).Maybe()
51 s := &session{log: testLog(), cmds: cmdR, events: evW, locker: h.lock, fader: h.fade, launcher: h.l}
52 go func() { h.done <- s.run(context.Background()) }()
53 t.Cleanup(func() { cmdW.Close(); evR.Close() })
54 return h
55}
56
57func (h *harness) sendLock(t *testing.T) {
58 require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualLock{Generation: gen, Auth: boot()}))
59}
60
61func (h *harness) event(t *testing.T) ports.VisualEvent {
62 t.Helper()
63 ch := make(chan ports.VisualEvent, 1)
64 go func() { ev, _ := visualproc.ReadEvent(h.events); ch <- ev }()
65 return recv(t, ch)
66}
67
68func TestLockReleasedOnlyAfterAuthSuccess(t *testing.T) {
69 h := newHarness(t)
70 w := expectWorker(t, h.l)
71 h.sendLock(t)
72 cfg := recv(t, h.cfgs)
73 w.bootstrap(t)
74 w.ready(t)
75 cfg.OnLocked()
76 require.Equal(t, ports.LockConfirmed{Generation: gen}, h.event(t))
77
78 cfg.OnSubmit([]byte("bad"))
79 f := w.attempt(t)
80 w.result(t, f.Attempt, ports.AuthDenied)
81 require.Equal(t, nefergui.LockFailed, recvFailed(t, cfg.Status))
82 select {
83 case err := <-h.done:
84 t.Fatalf("session ended after denial: %v", err)
85 case <-time.After(100 * time.Millisecond):
86 }
87
88 cfg.OnSubmit([]byte("good"))
89 f = w.attempt(t)
90 w.result(t, f.Attempt, ports.AuthSuccess)
91 require.Equal(t, ports.LockReleased{Generation: gen}, h.event(t))
92 require.NoError(t, recv(t, h.done))
93 recv(t, w.killed) // worker reaped with the lock
94}
95
96func recvFailed(t *testing.T, c <-chan nefergui.LockStatus) nefergui.LockStatus {
97 for {
98 if s := recv(t, c); s == nefergui.LockFailed {
99 return s
100 }
101 }
102}
103
104func TestWorkerCrashKeepsLock(t *testing.T) {
105 h := newHarness(t)
106 w := expectWorker(t, h.l)
107 h.sendLock(t)
108 cfg := recv(t, h.cfgs)
109 w.bootstrap(t)
110 w.ready(t)
111 cfg.OnLocked()
112 h.event(t)
113 cfg.OnSubmit([]byte("x"))
114 w.attempt(t)
115 w.toVisual.Close()
116 recv(t, w.killed)
117 recvFailed(t, cfg.Status)
118 select {
119 case err := <-h.done:
120 t.Fatalf("worker crash ended the lock: %v", err)
121 case <-time.After(100 * time.Millisecond):
122 }
123}
124
125func TestDaemonEOFKeepsLockUntilUnlock(t *testing.T) {
126 h := newHarness(t)
127 w := expectWorker(t, h.l)
128 h.sendLock(t)
129 cfg := recv(t, h.cfgs)
130 w.bootstrap(t)
131 w.ready(t)
132 cfg.OnLocked()
133 h.event(t)
134 h.cmdW.Close() // daemon gone
135 select {
136 case err := <-h.done:
137 t.Fatalf("EOF dropped an active lock: %v", err)
138 case <-time.After(100 * time.Millisecond):
139 }
140 cfg.OnSubmit([]byte("good"))
141 f := w.attempt(t)
142 w.result(t, f.Attempt, ports.AuthSuccess)
143 require.Equal(t, ports.LockReleased{Generation: gen}, h.event(t))
144 require.NoError(t, recv(t, h.done))
145}
146
147func TestFadeOnlyExitsOnEOF(t *testing.T) {
148 h := newHarness(t)
149 started := make(chan *fadeModel, 1)
150 h.fade.EXPECT().RunFade(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, m *fadeModel) error {
151 started <- m
152 <-ctx.Done()
153 return nil
154 }).Once()
155 require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualFade{Black: true, Duration: time.Second}))
156 m := recv(t, started)
157 require.Equal(t, fadeCmd{black: true, dur: time.Second}, recv(t, m.cmds))
158 h.cmdW.Close()
159 require.NoError(t, recv(t, h.done))
160}
161
162func TestRevealWithoutFadeExitsWithoutSpawning(t *testing.T) {
163 h := newHarness(t)
164 require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualFade{Black: false}))
165 require.NoError(t, recv(t, h.done))
166}
167
168func TestFinishedRevealExits(t *testing.T) {
169 h := newHarness(t)
170 release := make(chan struct{})
171 h.fade.EXPECT().RunFade(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, m *fadeModel) error {
172 <-release
173 return nil
174 }).Once()
175 require.NoError(t, visualproc.WriteCommand(h.cmdW, ports.VisualFade{Black: true}))
176 close(release)
177 require.NoError(t, recv(t, h.done))
178}
179
180// noEvent fails if the session emitted anything on the event pipe.
181func (h *harness) noEvent(t *testing.T) {
182 t.Helper()
183 got := make(chan ports.VisualEvent, 1)
184 go func() {
185 ev, err := visualproc.ReadEvent(h.events)
186 if err == nil {
187 got <- ev
188 }
189 }()
190 select {
191 case ev := <-got:
192 t.Fatalf("unexpected event %#v", ev)
193 case <-time.After(100 * time.Millisecond):
194 }
195}
196
197func TestRunLockNilWithoutAuthSuccessNeverReleases(t *testing.T) {
198 h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return nil })
199 w := expectWorker(t, h.l)
200 h.sendLock(t)
201 w.bootstrap(t)
202 err := recv(t, h.done)
203 require.ErrorIs(t, err, errLockWithoutAuth)
204 h.noEvent(t) // no LockReleased
205}
206
207func TestRunLockErrorNeverReleases(t *testing.T) {
208 for name, runErr := range map[string]error{
209 "finished": nefergui.ErrLockFinished,
210 "wrapped": fmt.Errorf("compositor: %w", nefergui.ErrLockFinished),
211 "other": errors.New("boom"),
212 } {
213 t.Run(name, func(t *testing.T) {
214 h := newHarnessRun(t, func(context.Context, nefergui.LockConfig) error { return runErr })
215 w := expectWorker(t, h.l)
216 h.sendLock(t)
217 w.bootstrap(t)
218 require.ErrorIs(t, recv(t, h.done), runErr)
219 h.noEvent(t)
220 })
221 }
222}