v0.3.2 a514042

manager_test.go RawHistoryFolder

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}

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