v0.3.1 4e89021

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	h := newHarness(t, cfgOf(step, 2*step, 3*step, 0), nil)
217	locks := h.lockCapture()
218	fades := make(chan ports.VisualFade, 4)
219	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
220	h.visual.EXPECT().Detach().Return(nil).Once() // shutdown with a held lock detaches, never closes
221	d := h.deadlines()
222	for _, after := range d.Deadlines {
223		h.send(ports.WaylandIdle{After: after})
224	}
225	l := lockOf(t, locks)
226	if l.Generation != 1 || l.Auth.Generation != 1 {
227		t.Fatalf("lock %+v", l)
228	}
229	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
230	if f := <-fades; !f.Black {
231		t.Fatal("fade not started")
232	}
233	if c := h.wlCommand(); c != (ports.OutputPower{On: false}) {
234		t.Fatalf("command %#v", c)
235	}
236	h.send(ports.WaylandActivity{})
237	if f := <-fades; f.Black {
238		t.Fatal("fade not reversed")
239	}
240	if c := h.wlCommand(); c != (ports.OutputPower{On: true}) {
241		t.Fatalf("command %#v", c)
242	}
243	st := h.status()
244	if !st.Protected || st.Faded || st.Off {
245		t.Fatalf("status %+v", st)
246	}
247	// Repeated activity, logind Unlock, and a reload: the lock stays held.
248	h.send(ports.WaylandActivity{})
249	h.sysEv <- ports.SystemUnlockObserved{}
250	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(step, 0, 0, 0)}
251	if st := h.status(); !st.Protected {
252		t.Fatalf("lock released: %+v", st)
253	}
254	h.stop()
255}
256
257func TestLockIsNeverForcedOrReordered(t *testing.T) {
258	// Fade only: no lock is acquired (mock has no Lock expectation).
259	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
260	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).Return(nil)
261	h.visual.EXPECT().Close().Return(nil).Once()
262	d := h.deadlines()
263	h.send(ports.WaylandIdle{After: d.Deadlines[0]})
264	h.status()
265	// Off and sleep share the lock's deadline: they wait for confirmation.
266	h2 := newHarness(t, cfgOf(step, 0, step, step), nil)
267	locks := h2.lockCapture()
268	h2.visual.EXPECT().Detach().Return(nil).Once()
269	d2 := h2.deadlines()
270	if len(d2.Deadlines) != 1 {
271		t.Fatalf("deadlines %v", d2.Deadlines)
272	}
273	h2.send(ports.WaylandIdle{After: step})
274	l := lockOf(t, locks)
275	h2.noWlCommand()
276	h2.noSysCommand()
277	h2.visEv <- ports.LockConfirmed{Generation: l.Generation}
278	if c := h2.wlCommand(); c != (ports.OutputPower{On: false}) {
279		t.Fatalf("command %#v", c)
280	}
281	if c := h2.sysCommand(); c != (ports.SystemSuspend{}) {
282		t.Fatalf("command %#v", c)
283	}
284}
285
286func TestReloadRearmsChangedDeadlinesAndRejectedConfigKeepsState(t *testing.T) {
287	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
288	h.visual.EXPECT().Close().Return(nil).Once()
289	if d := h.deadlines(); len(d.Deadlines) != 1 || d.Deadlines[0] != step {
290		t.Fatalf("%v", d.Deadlines)
291	}
292	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 3*step, 0, 0)}
293	d := h.deadlines()
294	if d.Generation != 2 || len(d.Deadlines) != 1 || d.Deadlines[0] != 3*step {
295		t.Fatalf("reload %+v", d)
296	}
297	// Identical config: no new arming. Invalid config: rejected, state kept.
298	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 3*step, 0, 0)}
299	bad := cfgOf(0, 3*step, 0, 0)
300	bad.LockAfter = -1
301	h.cfgCh <- ports.ConfigChanged{Config: bad}
302	h.fail <- errors.New("config reload rejected")
303	h.status()
304	h.noWlCommand()
305	// Disabling everything clears the deadlines.
306	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(0, 0, 0, 0)}
307	if d := h.deadlines(); len(d.Deadlines) != 0 {
308		t.Fatalf("still armed: %v", d.Deadlines)
309	}
310}
311
312func TestEqualDurationReloadDoesNotFireEarly(t *testing.T) {
313	// Lock (enabled) and fade share an idle duration only after the reload;
314	// the fade registration is new and must wait its own full duration even
315	// though the adapter's proxy for that duration is already idle.
316	after := 120 * time.Millisecond
317	h := newHarness(t, cfgOf(after, 0, 0, 0), nil)
318	locks := h.lockCapture()
319	fades := make(chan ports.VisualFade, 4)
320	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
321	h.visual.EXPECT().Detach().Return(nil).Once()
322	h.deadlines()
323	time.Sleep(after / 2)
324	h.cfgCh <- ports.ConfigChanged{Config: cfgOf(after, after, 0, 0)}
325	h.deadlines()
326	start := time.Now()
327	h.send(ports.WaylandIdle{After: after}) // proxy idles at the original origin
328	lockOf(t, locks)
329	select {
330	case <-fades:
331		if time.Since(start) < after/2-10*time.Millisecond {
332			t.Fatal("fade fired before its own deadline")
333		}
334	case <-time.After(wait):
335		t.Fatal("fade never fired")
336	}
337}
338
339func TestSleepReadyOnlyAfterLockConfirmed(t *testing.T) {
340	h := newHarness(t, cfgOf(time.Hour, 0, 0, 0), nil)
341	locks := h.lockCapture()
342	h.visual.EXPECT().Detach().Return(nil).Once()
343	h.deadlines()
344	h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 7}
345	l := lockOf(t, locks) // acquiring the lock is what BeforeSleep asks for
346	h.noSysCommand()      // ...and the inhibitor is held until it is confirmed
347	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
348	if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 7}) {
349		t.Fatalf("command %#v", c)
350	}
351	// Already protected: the next cycle is released immediately.
352	h.sysEv <- ports.SleepPreparation{Preparing: false, Cycle: 7}
353	h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 8}
354	if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 8}) {
355		t.Fatalf("command %#v", c)
356	}
357	h.noSysCommand()
358}
359
360func TestSleepWithLockDisabledIsReleasedWithoutLocking(t *testing.T) {
361	h := newHarness(t, cfgOf(0, 0, 0, time.Hour), nil) // no Lock expectation
362	h.visual.EXPECT().Close().Return(nil).Once()
363	h.deadlines()
364	h.sysEv <- ports.SleepPreparation{Preparing: true, Cycle: 1}
365	if c := h.sysCommand(); c != (ports.SystemSleepReady{Cycle: 1}) {
366		t.Fatalf("command %#v", c)
367	}
368}
369
370func TestLogindLockRequestAcquiresAndControlLockIsIdempotent(t *testing.T) {
371	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
372	locks := h.lockCapture()
373	h.visual.EXPECT().Detach().Return(nil).Once()
374	h.deadlines()
375	h.sysEv <- ports.SystemLockRequested{}
376	lockOf(t, locks)
377	reply := make(chan ports.ControlReply, 1)
378	h.ctl <- ports.ControlRequest{Kind: ports.ControlLock, Reply: reply}
379	if r := <-reply; !r.Acquiring || r.Err != "" {
380		t.Fatalf("reply %+v", r)
381	}
382	select {
383	case <-locks:
384		t.Fatal("second acquisition")
385	case <-time.After(60 * time.Millisecond):
386	}
387}
388
389func TestControlUnknownKindRejected(t *testing.T) {
390	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
391	h.visual.EXPECT().Close().Return(nil).Once()
392	reply := make(chan ports.ControlReply, 1)
393	h.ctl <- ports.ControlRequest{Kind: ports.ControlKind(99), Reply: reply}
394	if r := <-reply; r.Err == "" {
395		t.Fatal("unknown request accepted")
396	}
397}
398
399func TestVisualCrashKeepsLockAndRespawnsWithBackoff(t *testing.T) {
400	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
401	locks := make(chan ports.VisualLock, 8)
402	calls := 0
403	h.visual.EXPECT().Lock(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r ports.VisualLock) error {
404		calls++
405		locks <- r
406		if calls == 2 {
407			return errors.New("spawn failed") // first respawn attempt fails, retried
408		}
409		return nil
410	})
411	h.visual.EXPECT().Detach().Return(nil).Once()
412	h.deadlines()
413	h.sysEv <- ports.SystemLockRequested{}
414	l := lockOf(t, locks)
415	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
416	h.visEv <- ports.VisualExited{Err: errors.New("crash")}
417	for i := 0; i < 2; i++ {
418		if again := lockOf(t, locks); again.Generation != l.Generation {
419			t.Fatalf("respawn generation %d", again.Generation)
420		}
421	}
422	if st := h.status(); !st.Protected {
423		t.Fatalf("crash released the lock: %+v", st)
424	}
425}
426
427func TestVisualExitAfterReleaseDoesNotRespawn(t *testing.T) {
428	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
429	locks := h.lockCapture()
430	h.visual.EXPECT().Close().Return(nil).Once()
431	h.deadlines()
432	h.sysEv <- ports.SystemLockRequested{}
433	l := lockOf(t, locks)
434	h.visEv <- ports.LockConfirmed{Generation: l.Generation}
435	h.visEv <- ports.LockReleased{Generation: l.Generation}
436	h.visEv <- ports.VisualExited{}
437	if st := h.status(); st.Protected || st.Acquiring {
438		t.Fatalf("status %+v", st)
439	}
440	select {
441	case <-locks:
442		t.Fatal("respawned after successful unlock")
443	case <-time.After(60 * time.Millisecond):
444	}
445}
446
447func TestLockBootstrapCarriesMetadataAndEnvPINStaysOutOfChildEnvironment(t *testing.T) {
448	cfg := cfgOf(0, 0, 0, 0)
449	cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "NEFER_PIN"}
450	h := newHarness(t, cfg, func(o *Options) {
451		o.LookupEnv = func(k string) (string, bool) { return "123456", k == "NEFER_PIN" }
452	})
453	locks := h.lockCapture()
454	h.visual.EXPECT().Detach().Return(nil).Once()
455	h.deadlines()
456	h.sysEv <- ports.SystemLockRequested{}
457	l := lockOf(t, locks)
458	if l.Auth.PINSource != ports.PINSourceEnv || string(l.Auth.EnvPIN) != "123456" || len(l.Spawn.OmitEnv) != 1 || l.Spawn.OmitEnv[0] != "NEFER_PIN" {
459		t.Fatalf("lock %+v", l)
460	}
461	// Pass source: only the entry name travels, never a value.
462	cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourcePass, PINEntry: "afk/pin"}
463	h.cfgCh <- ports.ConfigChanged{Config: cfg}
464}
465
466func TestStartupGate(t *testing.T) {
467	ok := ports.Capabilities{}
468	for _, tc := range []struct {
469		name    string
470		wl      ports.Capabilities
471		sys     ports.SystemCapabilities
472		wantErr bool
473	}{
474		{"supported", ok, ports.SystemCapabilities{Session: true, Suspend: true}, false},
475		{"wayland missing", ports.Capabilities{Missing: []string{"ext_idle_notifier_v1 >= 2"}}, ports.SystemCapabilities{Session: true, Suspend: true}, true},
476		{"no session", ok, ports.SystemCapabilities{}, true},
477		{"suspend unavailable but sleep off", ok, ports.SystemCapabilities{Session: true, Missing: []string{"non-interactive suspend: no"}}, false},
478	} {
479		t.Run(tc.name, func(t *testing.T) {
480			h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 100*time.Millisecond })
481			h.visual.EXPECT().Close().Return(nil).Maybe()
482			h.send(ports.WaylandCapabilities{Capabilities: tc.wl})
483			h.sysEv <- ports.SystemCapabilityReport{Capabilities: tc.sys}
484			if tc.wantErr {
485				var u *UnsupportedError
486				if err := h.result(); !errors.As(err, &u) || len(u.Missing) == 0 {
487					t.Fatalf("err %v", err)
488				}
489				return
490			}
491			h.status()
492		})
493	}
494}
495
496func TestStartupGateWaitsForLateWaylandGlobals(t *testing.T) {
497	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
498		o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 80*time.Millisecond
499	})
500	h.visual.EXPECT().Close().Return(nil).Maybe()
501	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
502	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
503	h.send(ports.WaylandCapabilities{})
504	time.Sleep(3 * 80 * time.Millisecond)
505	select {
506	case err := <-h.done:
507		t.Fatalf("owner exited although the late report was complete: %v", err)
508	default:
509	}
510	h.status()
511}
512
513func TestStartupGateFailsWhenWaylandStaysIncomplete(t *testing.T) {
514	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
515		o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 50*time.Millisecond
516	})
517	h.visual.EXPECT().Close().Return(nil).Maybe()
518	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
519	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
520	var u *UnsupportedError
521	if err := h.result(); !errors.As(err, &u) || len(u.Missing) != 1 || u.Missing[0] != "wayland: wl_output >= 1" {
522		t.Fatalf("err %v", err)
523	}
524}
525
526func TestStartupGateFailsAtOnceWhenSystemMissing(t *testing.T) {
527	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.ExpectSystem, o.GateTimeout = true, true, time.Hour })
528	h.visual.EXPECT().Close().Return(nil).Maybe()
529	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Missing: []string{"wl_output >= 1"}}})
530	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{}}
531	var u *UnsupportedError
532	if err := h.result(); !errors.As(err, &u) || len(u.Missing) != 2 {
533		t.Fatalf("err %v", err)
534	}
535}
536
537// lockedBuffer is a real io.Writer shared by the owner and the test.
538type lockedBuffer struct {
539	mu sync.Mutex
540	b  strings.Builder
541}
542
543func (l *lockedBuffer) Write(p []byte) (int, error) {
544	l.mu.Lock()
545	defer l.mu.Unlock()
546	return l.b.Write(p)
547}
548func (l *lockedBuffer) String() string {
549	l.mu.Lock()
550	defer l.mu.Unlock()
551	return l.b.String()
552}
553
554func TestStartupGateSuspendUnavailableDoesNotBlockOtherActions(t *testing.T) {
555	logs := &lockedBuffer{}
556	h := newHarness(t, cfgOf(0, 0, 0, time.Hour), func(o *Options) {
557		o.Gate, o.ExpectSystem = true, true
558		o.Log = zerowrap.New(zerowrap.Config{Output: logs})
559	})
560	h.visual.EXPECT().Close().Return(nil).Maybe()
561	h.send(ports.WaylandCapabilities{})
562	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, SuspendAuthorization: "inhibited", Missing: []string{"non-interactive suspend: inhibited"}}}
563	h.status() // still running: only the sleep action is degraded
564	if !strings.Contains(logs.String(), "suspend unavailable (inhibited)") {
565		t.Fatalf("missing suspend warning in %q", logs.String())
566	}
567}
568
569func TestStartupGateSleepInhibitorStillRequired(t *testing.T) {
570	h := newHarness(t, cfgOf(0, 0, 0, time.Hour), func(o *Options) { o.Gate, o.ExpectSystem = true, true })
571	h.visual.EXPECT().Close().Return(nil).Maybe()
572	h.send(ports.WaylandCapabilities{})
573	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Missing: []string{"sleep delay inhibitor"}}}
574	var u *UnsupportedError
575	if err := h.result(); !errors.As(err, &u) {
576		t.Fatalf("err %v", err)
577	}
578}
579
580func TestNewOwnerValidation(t *testing.T) {
581	if _, err := NewOwner(Options{Config: ports.Defaults()}, Inputs{}, Outputs{}); err == nil {
582		t.Fatal("nil visual accepted")
583	}
584	bad := ports.Defaults()
585	bad.LockAfter = -1
586	if _, err := NewOwner(Options{Config: bad, Visual: portsmocks.NewMockVisual(t)}, Inputs{}, Outputs{}); err == nil {
587		t.Fatal("invalid config accepted")
588	}
589}
590
591func TestGatePassedSurvivesPastGateTimeout(t *testing.T) {
592	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) {
593		o.Gate, o.ExpectSystem, o.GateTimeout = true, true, 80*time.Millisecond
594	})
595	h.visual.EXPECT().Close().Return(nil).Maybe()
596	h.send(ports.WaylandCapabilities{})
597	h.sysEv <- ports.SystemCapabilityReport{Capabilities: ports.SystemCapabilities{Session: true, Suspend: true}}
598	time.Sleep(3 * 80 * time.Millisecond)
599	select {
600	case err := <-h.done:
601		t.Fatalf("owner exited after the gate passed: %v", err)
602	default:
603	}
604	h.status() // still serving
605}
606
607func TestGateTimeoutStillFailsWithoutReports(t *testing.T) {
608	h := newHarness(t, cfgOf(0, 0, 0, 0), func(o *Options) { o.Gate, o.GateTimeout = true, 50*time.Millisecond })
609	h.visual.EXPECT().Close().Return(nil).Maybe()
610	var u *UnsupportedError
611	if err := h.result(); !errors.As(err, &u) {
612		t.Fatalf("err %v", err)
613	}
614}
615
616func TestDeadlineNeedsEverySeatIdleAndActivityClearsOnlyItsSeat(t *testing.T) {
617	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
618	fades := make(chan ports.VisualFade, 4)
619	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
620	h.visual.EXPECT().Close().Return(nil).Once()
621	h.deadlines()
622	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1, 2}}})
623	h.send(ports.WaylandIdle{Seat: 1, After: step})
624	select {
625	case <-fades:
626		t.Fatal("fade fired with one of two seats idle")
627	case <-time.After(3 * step):
628	}
629	h.send(ports.WaylandIdle{Seat: 2, After: step})
630	if f := <-fades; !f.Black {
631		t.Fatal("fade not started once all seats idle")
632	}
633	// Seat 2 becomes active: undo, and only seat 2's idle state is cleared.
634	h.send(ports.WaylandActivity{Seat: 2})
635	if f := <-fades; f.Black {
636		t.Fatal("fade not reversed")
637	}
638	// Seat 1 stayed idle (its own state was not cleared), so seat 2 idling
639	// again completes "all seats idle" and the re-armed deadline fires anew...
640	h.send(ports.WaylandIdle{Seat: 2, After: step})
641	if f := <-fades; !f.Black {
642		t.Fatal("re-armed fade did not fire once seat 2 idled again")
643	}
644	// ...but activity on seat 1 clears seat 1 only: with seat 1 busy and seat 2
645	// idle, nothing may fire.
646	h.send(ports.WaylandActivity{Seat: 1})
647	if f := <-fades; f.Black {
648		t.Fatal("fade not reversed")
649	}
650	select {
651	case f := <-fades:
652		t.Fatalf("fired with seat 1 busy: %+v", f)
653	case <-time.After(3 * step):
654	}
655}
656
657func TestNothingFiresBeforeFirstSeatReport(t *testing.T) {
658	h, _ := newBareHarness(t, cfgOf(0, step, 0, 0), nil)
659	fades := make(chan ports.VisualFade, 4)
660	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
661	h.visual.EXPECT().Close().Return(nil).Once()
662	h.deadlines()
663	h.send(ports.WaylandIdle{Seat: 1, After: step})
664	select {
665	case f := <-fades:
666		t.Fatalf("fired before any seat report: %+v", f)
667	case <-time.After(3 * step):
668	}
669	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1}}})
670	if f := <-fades; !f.Black {
671		t.Fatal("deadline did not fire once the idle seat was reported")
672	}
673}
674
675func TestRemovedSeatNoLongerBlocksDeadline(t *testing.T) {
676	h := newHarness(t, cfgOf(0, step, 0, 0), nil)
677	fades := make(chan ports.VisualFade, 4)
678	h.visual.EXPECT().Fade(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.VisualFade) error { fades <- f; return nil })
679	h.visual.EXPECT().Close().Return(nil).Once()
680	h.deadlines()
681	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1, 2}}})
682	h.send(ports.WaylandIdle{Seat: 1, After: step})
683	h.send(ports.WaylandCapabilities{Capabilities: ports.Capabilities{Seats: []uint32{1}}})
684	if f := <-fades; !f.Black {
685		t.Fatal("removing the busy seat did not release the deadline")
686	}
687}
688
689func TestClosedOptionalChannelsAndNilReplyAreTolerated(t *testing.T) {
690	h := newHarness(t, cfgOf(0, 0, 0, 0), nil)
691	h.visual.EXPECT().Close().Return(nil).Once()
692	close(h.fail)
693	close(h.ctl)
694	time.Sleep(30 * time.Millisecond)
695	select {
696	case err := <-h.done:
697		t.Fatalf("owner died on closed optional channel: %v", err)
698	default:
699	}
700	// A request with no Reply channel must not panic or apply anything.
701	h2 := newHarness(t, cfgOf(0, 0, 0, 0), nil)
702	h2.visual.EXPECT().Close().Return(nil).Once()
703	h2.ctl <- ports.ControlRequest{Kind: ports.ControlLock}
704	if st := h2.status(); st.Acquiring || st.Protected {
705		t.Fatalf("nil-reply lock request applied: %+v", st)
706	}
707}
708
709func TestSpawnOmitsStartupAndCurrentPINEnvNames(t *testing.T) {
710	cfg := cfgOf(0, 0, 0, 0)
711	cfg.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "STARTUP_PIN"}
712	h := newHarness(t, cfg, nil)
713	locks := h.lockCapture()
714	h.visual.EXPECT().Detach().Return(nil).Once()
715	h.deadlines()
716	next := cfg
717	next.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourceEnv, PINEnv: "OTHER_PIN"}
718	h.cfgCh <- ports.ConfigChanged{Config: next}
719	h.sysEv <- ports.SystemLockRequested{}
720	l := lockOf(t, locks)
721	if len(l.Spawn.OmitEnv) != 2 || l.Spawn.OmitEnv[0] != "OTHER_PIN" || l.Spawn.OmitEnv[1] != "STARTUP_PIN" {
722		t.Fatalf("omit %v", l.Spawn.OmitEnv)
723	}
724	// Pass source after reload: the startup env name is still kept out.
725	next.Auth = ports.AuthConfig{Mode: ports.AuthModePIN, PINSource: ports.PINSourcePass, PINEntry: "afk/pin"}
726	h.cfgCh <- ports.ConfigChanged{Config: next}
727	h.status()
728}

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