1package visualproc
2
3import (
4 "context"
5 "errors"
6 "io"
7 "testing"
8
9 portsmocks "github.com/bnema/neferafk/internal/mocks/ports"
10 "github.com/bnema/neferafk/internal/ports"
11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/mock"
13)
14
15type rig struct {
16 launcher *portsmocks.MockVisualLauncher
17 out chan ports.VisualEvent
18 m *Manager
19}
20
21func newRig(t *testing.T) *rig {
22 r := &rig{launcher: portsmocks.NewMockVisualLauncher(t), out: make(chan ports.VisualEvent, 8)}
23 r.m = NewManager(context.Background(), r.launcher, r.out, zerowrap.New(zerowrap.Config{Output: io.Discard}))
24 return r
25}
26
27func newSession(t *testing.T) (*portsmocks.MockVisualSession, chan ports.VisualEvent) {
28 s := portsmocks.NewMockVisualSession(t)
29 ev := make(chan ports.VisualEvent, 4)
30 s.EXPECT().Events().Return((<-chan ports.VisualEvent)(ev)).Maybe()
31 return s, ev
32}
33
34func TestFadeRevealNeverSpawns(t *testing.T) {
35 r := newRig(t) // launcher has no expectations
36 if err := r.m.Fade(context.Background(), ports.VisualFade{}); err != nil {
37 t.Fatal(err)
38 }
39}
40
41func TestLazySpawnReuseAndLockIdempotencePerGeneration(t *testing.T) {
42 r := newRig(t)
43 s, ev := newSession(t)
44 spawn := ports.VisualSpawn{OmitEnv: []string{"PIN"}}
45 r.launcher.EXPECT().Launch(mock.Anything, spawn).Return(s, nil).Once()
46 s.EXPECT().Send(ports.VisualFade{Black: true, Spawn: spawn}).Return(nil).Once()
47 lock := ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1, EnvPIN: []byte("123456")}, Spawn: spawn}
48 s.EXPECT().Send(mock.MatchedBy(func(c ports.VisualCommand) bool { l, ok := c.(ports.VisualLock); return ok && l.Generation == 1 })).Return(nil).Once()
49 s.EXPECT().Send(mock.MatchedBy(func(c ports.VisualCommand) bool { l, ok := c.(ports.VisualLock); return ok && l.Generation == 2 })).Return(nil).Once()
50 if err := r.m.Fade(context.Background(), ports.VisualFade{Black: true, Spawn: spawn}); err != nil {
51 t.Fatal(err)
52 }
53 pin := lock.Auth.EnvPIN
54 if err := r.m.Lock(context.Background(), lock); err != nil {
55 t.Fatal(err)
56 }
57 if string(pin) == "123456" {
58 t.Fatal("manager did not wipe the PIN it owns")
59 }
60 if err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}); err != nil {
61 t.Fatal(err) // same generation: no second send (mock enforces)
62 }
63 if err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 2, Auth: ports.AuthBootstrap{Generation: 2}}); err != nil {
64 t.Fatal(err)
65 }
66 // Lifecycle events are forwarded in order.
67 ev <- ports.LockConfirmed{Generation: 2}
68 if got := <-r.out; got != (ports.LockConfirmed{Generation: 2}) {
69 t.Fatalf("%#v", got)
70 }
71 s.EXPECT().Close().Return(nil).Once()
72 if err := r.m.Close(); err != nil {
73 t.Fatal(err)
74 }
75}
76
77func TestCrashedVisualIsReplacedOnNextLock(t *testing.T) {
78 r := newRig(t)
79 s1, ev1 := newSession(t)
80 s2, _ := newSession(t)
81 r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s1, nil).Once()
82 r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s2, nil).Once()
83 s1.EXPECT().Send(mock.Anything).Return(nil).Once()
84 s1.EXPECT().Close().Return(nil).Once()
85 s2.EXPECT().Send(mock.Anything).Return(nil).Once()
86 s2.EXPECT().Detach().Return(nil).Once()
87 lock := ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}
88 if err := r.m.Lock(context.Background(), lock); err != nil {
89 t.Fatal(err)
90 }
91 ev1 <- ports.VisualExited{Err: errors.New("crash")}
92 if got, ok := (<-r.out).(ports.VisualExited); !ok || got.Err == nil {
93 t.Fatalf("%#v", got)
94 }
95 // Same generation, but the process is gone: a fresh visual re-acquires.
96 if err := r.m.Lock(context.Background(), lock); err != nil {
97 t.Fatal(err)
98 }
99 if err := r.m.Detach(); err != nil {
100 t.Fatal(err)
101 }
102}
103
104func TestWriteFailureRetriesOnFreshProcessThenReports(t *testing.T) {
105 r := newRig(t)
106 s1, _ := newSession(t)
107 s2, _ := newSession(t)
108 r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s1, nil).Once()
109 r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s2, nil).Once()
110 s1.EXPECT().Send(mock.Anything).Return(errors.New("broken pipe")).Once()
111 s1.EXPECT().Close().Return(nil).Once()
112 s2.EXPECT().Send(mock.Anything).Return(errors.New("broken pipe")).Once()
113 s2.EXPECT().Close().Return(nil).Once()
114 err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}})
115 if err == nil {
116 t.Fatal("unreachable visual reported success")
117 }
118 r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(nil, errors.New("exec failed")).Once()
119 if err := r.m.Lock(context.Background(), ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}); err == nil {
120 t.Fatal("launch failure swallowed")
121 }
122}
123
124func TestReleasedLockAllowsRelockOfSameGenerationNumber(t *testing.T) {
125 r := newRig(t)
126 s, ev := newSession(t)
127 r.launcher.EXPECT().Launch(mock.Anything, mock.Anything).Return(s, nil).Once()
128 s.EXPECT().Send(mock.Anything).Return(nil).Twice()
129 s.EXPECT().Close().Return(nil).Once()
130 lock := ports.VisualLock{Generation: 1, Auth: ports.AuthBootstrap{Generation: 1}}
131 r.m.Lock(context.Background(), lock)
132 ev <- ports.LockReleased{Generation: 1}
133 <-r.out
134 if err := r.m.Lock(context.Background(), lock); err != nil {
135 t.Fatal(err)
136 }
137 r.m.Close()
138}