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}