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