refactor/kvconf 3421f16

owner_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"io"
7	"strings"
8	"sync"
9	"testing"
10	"time"
11
12	portsmocks "github.com/bnema/neferafk/internal/mocks/ports"
13	"github.com/bnema/neferafk/internal/ports"
14	"github.com/bnema/zerowrap"
15	"github.com/stretchr/testify/mock"
16)
17
18const wait = 2 * time.Second
19
20type harness struct {
21	t      *testing.T
22	visual *portsmocks.MockVisual
23	cfgCh  chan ports.ConfigChanged
24	fail   chan error
25	wlEv   chan ports.WaylandEvent
26	sysEv  chan ports.SystemEvent
27	visEv  chan ports.VisualEvent
28	ctl    chan ports.ControlRequest
29	wlCmd  chan ports.WaylandCommand
30	sysCmd chan ports.SystemCommand
31	done   chan error
32	cancel context.CancelFunc
33	owner  *Owner
34}
35
36// newHarness starts an owner. Like the real Wayland adapter, non-gate harnesses
37// report one seat (0) before any idle event.
38func newHarness(t *testing.T, cfg ports.Config, tweak func(*Options)) *harness {
39	t.Helper()
40	h, gate := newBareHarness(t, cfg, tweak)
41	if !gate {
42		h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{0}}})
43	}
44	return h
45}
46
47// newBareHarness starts an owner without any capability report.
48func newBareHarness(t *testing.T, cfg ports.Config, tweak func(*Options)) (*harness, bool) {
49	t.Helper()
50	h := &harness{t: t, visual: portsmocks.NewMockVisual(t),
51		cfgCh: make(chan ports.ConfigChanged), fail: make(chan error, 4), wlEv: make(chan ports.WaylandEvent), sysEv: make(chan ports.SystemEvent),
52		visEv: make(chan ports.VisualEvent), ctl: make(chan ports.ControlRequest), wlCmd: make(chan ports.WaylandCommand, 32), sysCmd: make(chan ports.SystemCommand, 32), done: make(chan error, 1)}
53	opt := Options{Config: cfg, Visual: h.visual, Log: zerowrap.New(zerowrap.Config{Output: io.Discard}), RetryMin: 5 * time.Millisecond, RetryMax: 20 * time.Millisecond}
54	if tweak != nil {
55		tweak(&opt)
56	}
57	o, err := NewOwner(opt, Inputs{Config: h.cfgCh, Failures: h.fail, Wayland: h.wlEv, System: h.sysEv, Visual: h.visEv, Control: h.ctl}, Outputs{Wayland: h.wlCmd, System: h.sysCmd})
58	if err != nil {
59		t.Fatal(err)
60	}
61	h.owner = o
62	ctx, cancel := context.WithCancel(context.Background())
63	h.cancel = cancel
64	go func() { h.done <- o.Run(ctx) }()
65	t.Cleanup(func() { h.stop() })
66	return h, opt.Gate
67}
68
69// stop ends the owner (once) and returns its error.
70func (h *harness) stop() error {
71	h.cancel()
72	select {
73	case err := <-h.done:
74		h.done <- err
75		return err
76	case <-time.After(wait):
77		h.t.Fatal("owner did not stop")
78		return nil
79	}
80}
81
82// result waits for the owner to exit on its own (without cancelling it).
83func (h *harness) result() error {
84	select {
85	case err := <-h.done:
86		h.done <- err
87		return err
88	case <-time.After(wait):
89		h.t.Fatal("owner did not exit")
90		return nil
91	}
92}
93
94func (h *harness) deadlines() ports.SetDeadlines {
95	h.t.Helper()
96	for {
97		select {
98		case c := <-h.wlCmd:
99			if d, ok := c.(ports.SetDeadlines); ok {
100				return d
101			}
102		case <-time.After(wait):
103			h.t.Fatal("no SetDeadlines")
104		}
105	}
106}
107func (h *harness) wlCommand() ports.WaylandCommand {
108	h.t.Helper()
109	select {
110	case c := <-h.wlCmd:
111		return c
112	case <-time.After(wait):
113		h.t.Fatal("no wayland command")
114		return nil
115	}
116}
117func (h *harness) noWlCommand() {
118	h.t.Helper()
119	select {
120	case c := <-h.wlCmd:
121		h.t.Fatalf("unexpected wayland command %#v", c)
122	case <-time.After(60 * time.Millisecond):
123	}
124}
125func (h *harness) sysCommand() ports.SystemCommand {
126	h.t.Helper()
127	select {
128	case c := <-h.sysCmd:
129		return c
130	case <-time.After(wait):
131		h.t.Fatal("no system command")
132		return nil
133	}
134}
135func (h *harness) noSysCommand() {
136	h.t.Helper()
137	select {
138	case c := <-h.sysCmd:
139		h.t.Fatalf("unexpected system command %#v", c)
140	case <-time.After(60 * time.Millisecond):
141	}
142}
143func (h *harness) send(ev ports.WaylandEvent) {
144	h.t.Helper()
145	select {
146	case h.wlEv <- ev:
147	case <-time.After(wait):
148		h.t.Fatal("owner stuck")
149	}
150}
151func (h *harness) status() ports.ControlReply {
152	h.t.Helper()
153	reply := make(chan ports.ControlReply, 1)
154	select {
155	case h.ctl <- ports.ControlRequest{Kind: ports.ControlStatus, Reply: reply}:
156	case <-time.After(wait):
157		h.t.Fatal("owner stuck")
158	}
159	return <-reply
160}
161
162func cfgOf(lock, fade, off, sleep time.Duration) ports.Config {
163	c := ports.Defaults()
164	c.LockAfter, c.FadeAfter, c.OffAfter, c.SleepAfter = lock, fade, off, sleep
165	c.FadeDuration = time.Second
166	return c
167}
168
169const step = 30 * time.Millisecond
170
171// lockCapture makes Visual.Lock record its request.
172func (h *harness) lockCapture() <-chan ports.VisualLock {
173	locks := make(chan ports.VisualLock, 8)
174	h.visual.EXPECT().Lock(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r ports.VisualLock) error {
175		locks <- r
176		return nil
177	})
178	return locks
179}
180func lockOf(t *testing.T, ch <-chan ports.VisualLock) ports.VisualLock {
181	t.Helper()
182	select {
183	case l := <-ch:
184		return l
185	case <-time.After(wait):
186		t.Fatal("no lock request")
187		return ports.VisualLock{}
188	}
189}
190
191func TestIdleActionsAreIndependentAndDisabledOnesNeverFire(t *testing.T) {
192	// Only fade and output-off are enabled; lock and sleep are "off".
193	h := newHarness(t, cfgOf(0, step, 2*step, 0), nil)
194	fades := make(chan ports.VisualFade, 4)
195	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
196	h.visual.EXPECT().Close().Return(nil).Once()
197	d := h.deadlines()
198	if len(d.Deadlines) != 2 || d.Deadlines[0] != step || d.Deadlines[1] != 2*step {
199		t.Fatalf("deadlines %v", d.Deadlines)
200	}
201	h.send(ports.WaylandIdle{After: step})
202	if f := <-fades; !f.Black || f.Duration != time.Second {
203		t.Fatalf("fade %+v", f)
204	}
205	h.send(ports.WaylandIdle{After: 2 * step})
206	if c := h.wlCommand(); c != (ports.OutputPower{On: false}) {
207		t.Fatalf("command %#v", c)
208	}
209	h.noSysCommand() // sleep is off: nothing suspends
210	if st := h.status(); st.Acquiring || st.Protected {
211		t.Fatalf("locked without lock.after: %+v", st)
212	}
213}
214
215func TestActivityUndoesFadeWakesOutputsButNeverUnlocks(t *testing.T) {
216	cfg := cfgOf(step, 2*step, 3*step, 0)
217	cfg.LockOutput = "DP-2"
218	h := newHarness(t, cfg, nil)
219	locks := h.lockCapture()
220	fades := make(chan ports.VisualFade, 4)
221	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
222	h.visual.EXPECT().Detach().Return(nil).Once() // shutdown with a held lock detaches, never closes
223	d := h.deadlines()
224	for _, after := range d.Deadlines {
225		h.send(ports.WaylandIdle{After: after})
226	}
227	l := lockOf(t, locks)
228	if l.Generation != 1 || l.Auth.Generation != 1 || l.Output != "DP-2" {
229		t.Fatalf("lock %+v", l)
230	}
231	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
232	if f := <-fades; !f.Black {
233		t.Fatal("fade not started")
234	}
235	if c := h.wlCommand(); c != (ports.OutputPower{On: false}) {
236		t.Fatalf("command %#v", c)
237	}
238	h.send(ports.WaylandActivity{})
239	if f := <-fades; f.Black {
240		t.Fatal("fade not reversed")
241	}
242	if c := h.wlCommand(); c != (ports.OutputPower{On: true}) {
243		t.Fatalf("command %#v", c)
244	}
245	st := h.status()
246	if !st.Protected || st.Faded || st.Off {
247		t.Fatalf("status %+v", st)
248	}
249	// Repeated activity, logind Unlock, and a reload: the lock stays held.
250	h.send(ports.WaylandActivity{})
251	h.sysEv <- ports.SystemUnlockObserved{}
252	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(step, 0, 0, 0)}
253	if st := h.status(); !st.Protected {
254		t.Fatalf("lock released: %+v", st)
255	}
256	h.stop()
257}
258
259func TestLockIsNeverForcedOrReordered(t *testing.T) {
260	// Fade only: no lock is acquired (mock has no Lock expectation).
261	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
262	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).Return(nil)
263	h.visual.EXPECT().Close().Return(nil).Once()
264	d := h.deadlines()
265	h.send(ports.WaylandIdle{After: d.Deadlines[0]})
266	h.status()
267	// Off and sleep share the lock's deadline: they wait for confirmation.
268	h2 := newHarness(t, cfgOf(step, 0, step, step), nil)
269	locks := h2.lockCapture()
270	h2.visual.EXPECT().Detach().Return(nil).Once()
271	d2 := h2.deadlines()
272	if len(d2.Deadlines) != 1 {
273		t.Fatalf("deadlines %v", d2.Deadlines)
274	}
275	h2.send(ports.WaylandIdle{After: step})
276	l := lockOf(t, locks)
277	h2.noWlCommand()
278	h2.noSysCommand()
279	h2.visEv <- ports.LockConfirmed{Generation: l.Generation}
280	if c := h2.wlCommand(); c != (ports.OutputPower{On: false}) {
281		t.Fatalf("command %#v", c)
282	}
283	if c := h2.sysCommand(); c != (ports.SystemSuspend{}) {
284		t.Fatalf("command %#v", c)
285	}
286}
287
288func TestReloadRearmsChangedDeadlinesAndRejectedConfigKeepsState(t *testing.T) {
289	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
290	h.visual.EXPECT().Close().Return(nil).Once()
291	if d := h.deadlines(); len(d.Deadlines) != 1 || d.Deadlines[0] != step {
292		t.Fatalf("%v", d.Deadlines)
293	}
294	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 3*step, 0, 0)}
295	d := h.deadlines()
296	if d.Generation != 2 || len(d.Deadlines) != 1 || d.Deadlines[0] != 3*step {
297		t.Fatalf("reload %+v", d)
298	}
299	// Identical config: no new arming. Invalid config: rejected, state kept.
300	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 3*step, 0, 0)}
301	bad := cfgOf(0, 3*step, 0, 0)
302	bad.LockAfter = -1
303	h.cfgCh <- ports.ConfigChanged{Config: bad}
304	h.fail <- errors.New("config reload rejected")
305	h.status()
306	h.noWlCommand()
307	// Disabling everything clears the deadlines.
308	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 0, 0, 0)}
309	if d := h.deadlines(); len(d.Deadlines) != 0 {
310		t.Fatalf("still armed: %v", d.Deadlines)
311	}
312}
313
314func TestEqualDurationReloadDoesNotFireEarly(t *testing.T) {
315	// Lock (enabled) and fade share an idle duration only after the reload;
316	// the fade registration is new and must wait its own full duration even
317	// though the adapter's proxy for that duration is already idle.
318	after := 120 * time.Millisecond
319	h := newHarness(t, cfgOf(after, 0, 0, 0), nil)
320	locks := h.lockCapture()
321	fades := make(chan ports.VisualFade, 4)
322	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
323	h.visual.EXPECT().Detach().Return(nil).Once()
324	h.deadlines()
325	time.Sleep(after / 2)
326	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(after, after, 0, 0)}
327	h.deadlines()
328	start := time.Now()
329	h.send(ports.WaylandIdle{After: after}) // proxy idles at the original origin
330	lockOf(t, locks)
331	select {
332	case <-fades:
333		if time.Since(start) < after/2-10*time.Millisecond {
334			t.Fatal("fade fired before its own deadline")
335		}
336	case <-time.After(wait):
337		t.Fatal("fade never fired")
338	}
339}
340
341func TestSleepReadyOnlyAfterLockConfirmed(t *testing.T) {
342	h := newHarness(t, cfgOf(time.Hour, 0, 0, 0), nil)
343	locks := h.lockCapture()
344	h.visual.EXPECT().Detach().Return(nil).Once()
345	h.deadlines()
346	h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 7}
347	l := lockOf(t, locks) // acquiring the lock is what BeforeSleep asks for
348	h.noSysCommand()      // ...and the inhibitor is held until it is confirmed
349	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
350	if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 7}) {
351		t.Fatalf("command %#v", c)
352	}
353	// Already protected: the next cycle is released immediately.
354	h.sysEv <- ports.SleepPreparation{Preparing: false, Cycle: 7}
355	h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 8}
356	if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 8}) {
357		t.Fatalf("command %#v", c)
358	}
359	h.noSysCommand()
360}
361
362func TestSleepWithLockDisabledIsReleasedWithoutLocking(t *testing.T) {
363	h := newHarness(t, cfgOf(0, 0, 0, time.Hour), nil) // no Lock expectation
364	h.visual.EXPECT().Close().Return(nil).Once()
365	h.deadlines()
366	h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 1}
367	if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 1}) {
368		t.Fatalf("command %#v", c)
369	}
370}
371
372func TestLogindLockRequestAcquiresAndControlLockIsIdempotent(t *testing.T) {
373	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
374	locks := h.lockCapture()
375	h.visual.EXPECT().Detach().Return(nil).Once()
376	h.deadlines()
377	h.sysEv <- ports.SystemLockRequested{}
378	lockOf(t, locks)
379	reply := make(chan ports.ControlReply, 1)
380	h.ctl <- ports.ControlRequest{Kind: ports.ControlLock, Reply: reply}
381	if r := <-reply; !r.Acquiring || r.Err != "" {
382		t.Fatalf("reply %+v", r)
383	}
384	select {
385	case <-locks:
386		t.Fatal("second acquisition")
387	case <-time.After(60 * time.Millisecond):
388	}
389}
390
391func TestControlUnknownKindRejected(t *testing.T) {
392	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
393	h.visual.EXPECT().Close().Return(nil).Once()
394	reply := make(chan ports.ControlReply, 1)
395	h.ctl <- ports.ControlRequest{Kind: ports.ControlKind(99), Reply: reply}
396	if r := <-reply; r.Err == "" {
397		t.Fatal("unknown request accepted")
398	}
399}
400
401func TestVisualCrashKeepsLockAndRespawnsWithBackoff(t *testing.T) {
402	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
403	locks := make(chan ports.VisualLock, 8)
404	calls := 0
405	h.visual.EXPECT().Lock(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r ports.VisualLock) error {
406		calls++
407		locks <- r
408		if calls == 2 {
409			return errors.New("spawn failed") // first respawn attempt fails, retried
410		}
411		return nil
412	})
413	h.visual.EXPECT().Detach().Return(nil).Once()
414	h.deadlines()
415	h.sysEv <- ports.SystemLockRequested{}
416	l := lockOf(t, locks)
417	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
418	h.visEv <- ports.VisualExited{Err: errors.New("crash")}
419	for i := 0; i < 2; i++ {
420		if again := lockOf(t, locks); again.Generation != l.Generation {
421			t.Fatalf("respawn generation %d", again.Generation)
422		}
423	}
424	if st := h.status(); !st.Protected {
425		t.Fatalf("crash released the lock: %+v", st)
426	}
427}
428
429func TestVisualExitAfterReleaseDoesNotRespawn(t *testing.T) {
430	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
431	locks := h.lockCapture()
432	h.visual.EXPECT().Close().Return(nil).Once()
433	h.deadlines()
434	h.sysEv <- ports.SystemLockRequested{}
435	l := lockOf(t, locks)
436	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
437	h.visEv <- ports.LockReleased{Generation: l.Generation}
438	h.visEv <- ports.VisualExited{}
439	if st := h.status(); st.Protected || st.Acquiring {
440		t.Fatalf("status %+v", st)
441	}
442	select {
443	case <-locks:
444		t.Fatal("respawned after successful unlock")
445	case <-time.After(60 * time.Millisecond):
446	}
447}
448
449func TestLockBootstrapCarriesMetadataAndEnvPINStaysOutOfChildEnvironment(t *testing.T) {
450	cfg := cfgOf(0, 0, 0, 0)
451	cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "NEFER_PIN"}
452	h := newHarness(t, cfg, func(o *Options) {
453		o.LookupEnv = func(k string) (string, bool) { return "123456", k == "NEFER_PIN" }
454	})
455	locks := h.lockCapture()
456	h.visual.EXPECT().Detach().Return(nil).Once()
457	h.deadlines()
458	h.sysEv <- ports.SystemLockRequested{}
459	l := lockOf(t, locks)
460	if l.Auth.PINSource != ports.PINSourceEnv || string(l.Auth.EnvPIN) != "123456" || len(l.Spawn.OmitEnv) != 1 || l.Spawn.OmitEnv[0] != "NEFER_PIN" {
461		t.Fatalf("lock %+v", l)
462	}
463	// Pass source: only the entry name travels, never a value.
464	cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourcePass, PINEntry: "afk/pin"}
465	h.cfgCh <- ports.ConfigChanged{Config: cfg}
466}
467
468func TestStartupGate(t *testing.T) {
469	ok := ports.Capabilities{}
470	for _, tc := range []struct {
471		name    string
472		wl      ports.Capabilities
473		sys     ports.SystemCapabilities
474		wantErr bool
475	}{
476		{"supported", ok, ports.SystemCapabilities{Session: true, Suspend: true}, false},
477		{"wayland missing", ports.Capabilities{Missing: []string{"ext_idle_notifier_v1 >= 2"}}, ports.SystemCapabilities{Session: true, Suspend: true}, true},
478		{"no session", ok, ports.SystemCapabilities{}, true},
479		{"suspend unavailable but sleep off", ok, ports.SystemCapabilities{Session: true, Missing: []string{"non-interactive suspend: no"}}, false},
480	} {
481		t.Run(tc.name, func(t *testing.T) {
482			h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 100*time.Millisecond })
483			h.visual.EXPECT().Close().Return(nil).Maybe()
484			h.send(ports.WaylandCapabilities{Capabilities: tc.wl})
485			h.sysEv <- ports.SystemCapabilityReport{Capabilities: tc.sys}
486			if tc.wantErr {
487				var u *UnsupportedError
488				if err := h.result(); !errors.As(err, &u) || len(u.Missing) == 0 {
489					t.Fatalf("err %v", err)
490				}
491				return
492			}
493			h.status()
494		})
495	}
496}
497
498func TestStartupGateWaitsForLateWaylandGlobals(t *testing.T) {
499	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
500		o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 80*time.Millisecond
501	})
502	h.visual.EXPECT().Close().Return(nil).Maybe()
503	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
504	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
505	h.send(ports.WaylandCapabilities{})
506	time.Sleep(3 * 80 * time.Millisecond)
507	select {
508	case err := <-h.done:
509		t.Fatalf("owner exited although the late report was complete: %v", err)
510	default:
511	}
512	h.status()
513}
514
515func TestStartupGateFailsWhenWaylandStaysIncomplete(t *testing.T) {
516	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
517		o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 50*time.Millisecond
518	})
519	h.visual.EXPECT().Close().Return(nil).Maybe()
520	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
521	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
522	var u *UnsupportedError
523	if err := h.result(); !errors.As(err, &u) || len(u.Missing) != 1 || u.Missing[0] != "wayland: wl_output >= 1" {
524		t.Fatalf("err %v", err)
525	}
526}
527
528func TestStartupGateFailsAtOnceWhenSystemMissing(t *testing.T) {
529	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.ExpectSystem, o.GateTimeout = true, true, time.Hour })
530	h.visual.EXPECT().Close().Return(nil).Maybe()
531	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
532	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{}}
533	var u *UnsupportedError
534	if err := h.result(); !errors.As(err, &u) || len(u.Missing) != 2 {
535		t.Fatalf("err %v", err)
536	}
537}
538
539// lockedBuffer is a real io.Writer shared by the owner and the test.
540type lockedBuffer struct {
541	mu sync.Mutex
542	b  strings.Builder
543}
544
545func (l *lockedBuffer) Write(p []byte) (int, error) {
546	l.mu.Lock()
547	defer l.mu.Unlock()
548	return l.b.Write(p)
549}
550func (l *lockedBuffer) String() string {
551	l.mu.Lock()
552	defer l.mu.Unlock()
553	return l.b.String()
554}
555
556func TestStartupGateSuspendUnavailableDoesNotBlockOtherActions(t *testing.T) {
557	logs := &lockedBuffer{}
558	h := newHarness(t, cfgOf(0, 0, 0, time.Hour), func(o *Options) {
559		o.Gate, o.ExpectSystem = true, true
560		o.Log = zerowrap.New(zerowrap.Config{Output: logs})
561	})
562	h.visual.EXPECT().Close().Return(nil).Maybe()
563	h.send(ports.WaylandCapabilities{})
564	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, SuspendAuthorization: "inhibited", Missing: []string{"non-interactive suspend: inhibited"}}}
565	h.status() // still running: only the sleep action is degraded
566	if !strings.Contains(logs.String(), "suspend unavailable (inhibited)") {
567		t.Fatalf("missing suspend warning in %q", logs.String())
568	}
569}
570
571func TestStartupGateSleepInhibitorStillRequired(t *testing.T) {
572	h := newHarness(t, cfgOf(0, 0, 0, time.Hour), func(o *Options) { o.Gate, o.ExpectSystem = true, true })
573	h.visual.EXPECT().Close().Return(nil).Maybe()
574	h.send(ports.WaylandCapabilities{})
575	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Missing: []string{"sleep delay inhibitor"}}}
576	var u *UnsupportedError
577	if err := h.result(); !errors.As(err, &u) {
578		t.Fatalf("err %v", err)
579	}
580}
581
582func TestNewOwnerValidation(t *testing.T) {
583	if _, err := NewOwner(Options{Config: ports.Defaults()}, Inputs{}, Outputs{}); err == nil {
584		t.Fatal("nil visual accepted")
585	}
586	bad := ports.Defaults()
587	bad.LockAfter = -1
588	if _, err := NewOwner(Options{Config: bad, Visual: portsmocks.NewMockVisual(t)}, Inputs{}, Outputs{}); err == nil {
589		t.Fatal("invalid config accepted")
590	}
591}
592
593func TestGatePassedSurvivesPastGateTimeout(t *testing.T) {
594	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
595		o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 80*time.Millisecond
596	})
597	h.visual.EXPECT().Close().Return(nil).Maybe()
598	h.send(ports.WaylandCapabilities{})
599	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
600	time.Sleep(3 * 80 * time.Millisecond)
601	select {
602	case err := <-h.done:
603		t.Fatalf("owner exited after the gate passed: %v", err)
604	default:
605	}
606	h.status() // still serving
607}
608
609func TestGateTimeoutStillFailsWithoutReports(t *testing.T) {
610	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.GateTimeout = true, 50*time.Millisecond })
611	h.visual.EXPECT().Close().Return(nil).Maybe()
612	var u *UnsupportedError
613	if err := h.result(); !errors.As(err, &u) {
614		t.Fatalf("err %v", err)
615	}
616}
617
618func TestDeadlineNeedsEverySeatIdleAndActivityClearsOnlyItsSeat(t *testing.T) {
619	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
620	fades := make(chan ports.VisualFade, 4)
621	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
622	h.visual.EXPECT().Close().Return(nil).Once()
623	h.deadlines()
624	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1, 2}}})
625	h.send(ports.WaylandIdle{Seat: 1, After: step})
626	select {
627	case <-fades:
628		t.Fatal("fade fired with one of two seats idle")
629	case <-time.After(3 * step):
630	}
631	h.send(ports.WaylandIdle{Seat: 2, After: step})
632	if f := <-fades; !f.Black {
633		t.Fatal("fade not started once all seats idle")
634	}
635	// Seat 2 becomes active: undo, and only seat 2's idle state is cleared.
636	h.send(ports.WaylandActivity{Seat: 2})
637	if f := <-fades; f.Black {
638		t.Fatal("fade not reversed")
639	}
640	// Seat 1 stayed idle (its own state was not cleared), so seat 2 idling
641	// again completes "all seats idle" and the re-armed deadline fires anew...
642	h.send(ports.WaylandIdle{Seat: 2, After: step})
643	if f := <-fades; !f.Black {
644		t.Fatal("re-armed fade did not fire once seat 2 idled again")
645	}
646	// ...but activity on seat 1 clears seat 1 only: with seat 1 busy and seat 2
647	// idle, nothing may fire.
648	h.send(ports.WaylandActivity{Seat: 1})
649	if f := <-fades; f.Black {
650		t.Fatal("fade not reversed")
651	}
652	select {
653	case f := <-fades:
654		t.Fatalf("fired with seat 1 busy: %+v", f)
655	case <-time.After(3 * step):
656	}
657}
658
659func TestNothingFiresBeforeFirstSeatReport(t *testing.T) {
660	h, _ := newBareHarness(t, cfgOf(0, step, 0, 0), nil)
661	fades := make(chan ports.VisualFade, 4)
662	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
663	h.visual.EXPECT().Close().Return(nil).Once()
664	h.deadlines()
665	h.send(ports.WaylandIdle{Seat: 1, After: step})
666	select {
667	case f := <-fades:
668		t.Fatalf("fired before any seat report: %+v", f)
669	case <-time.After(3 * step):
670	}
671	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1}}})
672	if f := <-fades; !f.Black {
673		t.Fatal("deadline did not fire once the idle seat was reported")
674	}
675}
676
677func TestRemovedSeatNoLongerBlocksDeadline(t *testing.T) {
678	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
679	fades := make(chan ports.VisualFade, 4)
680	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
681	h.visual.EXPECT().Close().Return(nil).Once()
682	h.deadlines()
683	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1, 2}}})
684	h.send(ports.WaylandIdle{Seat: 1, After: step})
685	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1}}})
686	if f := <-fades; !f.Black {
687		t.Fatal("removing the busy seat did not release the deadline")
688	}
689}
690
691func TestClosedOptionalChannelsAndNilReplyAreTolerated(t *testing.T) {
692	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
693	h.visual.EXPECT().Close().Return(nil).Once()
694	close(h.fail)
695	close(h.ctl)
696	time.Sleep(30 * time.Millisecond)
697	select {
698	case err := <-h.done:
699		t.Fatalf("owner died on closed optional channel: %v", err)
700	default:
701	}
702	// A request with no Reply channel must not panic or apply anything.
703	h2 := newHarness(t, cfgOf(0, 0, 0, 0), nil)
704	h2.visual.EXPECT().Close().Return(nil).Once()
705	h2.ctl <- ports.ControlRequest{Kind: ports.ControlLock}
706	if st := h2.status(); st.Acquiring || st.Protected {
707		t.Fatalf("nil-reply lock request applied: %+v", st)
708	}
709}
710
711func TestSpawnOmitsStartupAndCurrentPINEnvNames(t *testing.T) {
712	cfg := cfgOf(0, 0, 0, 0)
713	cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "STARTUP_PIN"}
714	h := newHarness(t, cfg, nil)
715	locks := h.lockCapture()
716	h.visual.EXPECT().Detach().Return(nil).Once()
717	h.deadlines()
718	next := cfg
719	next.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "OTHER_PIN"}
720	h.cfgCh <- ports.ConfigChanged{Config: next}
721	h.sysEv <- ports.SystemLockRequested{}
722	l := lockOf(t, locks)
723	if len(l.Spawn.OmitEnv) != 2 || l.Spawn.OmitEnv[0] != "OTHER_PIN" || l.Spawn.OmitEnv[1] != "STARTUP_PIN" {
724		t.Fatalf("omit %v", l.Spawn.OmitEnv)
725	}
726	// Pass source after reload: the startup env name is still kept out.
727	next.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourcePass, PINEntry: "afk/pin"}
728	h.cfgCh <- ports.ConfigChanged{Config: next}
729	h.status()
730}

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