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}