1package app
2
3import (
4 "context"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9
10 wlr "github.com/bnema/go-wayland-bindings/server/wlroutputmanagement"
11 "github.com/bnema/neferwl/internal/adapters/config"
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/bnema/wlturbo"
14 client "github.com/bnema/wlturbo/protocol/outputmanagement"
15)
16
17// A wlr-output-management client goes through Wayland, the headless owner
18// loop and core: an accepted scale reaches the scene, a disable is refused.
19func TestHeadlessOutputManagementEndToEnd(t *testing.T) {
20 runtime := t.TempDir()
21 t.Setenv("XDG_RUNTIME_DIR", runtime)
22 scenes := make(chan []ports.Scene, 64)
23 ctx, cancel := context.WithCancel(context.Background())
24 done := make(chan error, 1)
25 go func() {
26 done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Sizes: [][2]int{{640, 480}}, testScenes: scenes})
27 }()
28 // A stuck loop never answers the client: the watchdog stops the app,
29 // which closes the socket, so a blocked Roundtrip fails instead of hanging.
30 watchdog := time.AfterFunc(10*time.Second, cancel)
31 exited := false
32 t.Cleanup(func() {
33 watchdog.Stop()
34 cancel()
35 if exited {
36 return
37 }
38 select {
39 case err := <-done:
40 if err != nil {
41 t.Errorf("Run: %v", err)
42 }
43 case <-time.After(3 * time.Second):
44 t.Error("Run did not stop")
45 }
46 })
47 // The first scene means the Wayland socket exists.
48 scene := func(want func(ports.Scene) bool) {
49 t.Helper()
50 deadline := time.After(5 * time.Second)
51 for {
52 select {
53 case set := <-scenes:
54 if len(set) == 1 && want(set[0]) {
55 return
56 }
57 case err := <-done:
58 exited = true
59 if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
60 t.Skipf("Vulkan unavailable: %v", err)
61 }
62 t.Fatalf("app exited early: %v", err)
63 case <-deadline:
64 t.Fatal("scene missing")
65 }
66 }
67 }
68 scene(func(s ports.Scene) bool { return s.Scale == 1 })
69 sockets, _ := filepath.Glob(filepath.Join(runtime, "wayland-*[0-9]"))
70 if len(sockets) != 1 {
71 t.Fatalf("sockets: %v", sockets)
72 }
73 c, err := wlturbo.Connect(sockets[0])
74 if err != nil {
75 t.Fatal(err)
76 }
77 t.Cleanup(func() { _ = c.Close() })
78 if err := c.Roundtrip(); err != nil {
79 t.Fatal(err)
80 }
81 manager := client.NewOutputManager(c.Context())
82 var heads []*client.OutputHead
83 var serial uint32
84 manager.OnHead(func(head *client.OutputHead) { heads = append(heads, head) })
85 manager.OnDone(func(value uint32) { serial = value })
86 bindTestClient(t, c, client.OutputManagerInterface, 4, manager)
87 roundtrip := func(until func() bool) {
88 t.Helper()
89 // The owner loops answer asynchronously: roundtrip until they do,
90 // or until the watchdog stops the app.
91 for !until() {
92 if err := c.Roundtrip(); err != nil {
93 t.Fatalf("no answer: %v", err)
94 }
95 }
96 }
97 // The inventory reaches Wayland through the owner loop's outbox.
98 roundtrip(func() bool { return serial != 0 && len(heads) == 1 })
99 head, initialSerial := heads[0], serial
100 configure := func(set func(*client.OutputConfiguration) *client.OutputConfigurationHead) int {
101 t.Helper()
102 // Inventory and core layout are independent startup publications. A
103 // done serial can become stale before create_configuration reaches the
104 // server. Only protocol cancellation is retryable, never a failed apply.
105 for attempt := 0; attempt < 3; attempt++ {
106 requested := serial
107 cfg, err := manager.CreateConfiguration(requested)
108 if err != nil {
109 t.Fatal(err)
110 }
111 result := -1
112 cfg.OnSucceeded(func() { result = int(wlr.ZwlrOutputConfigurationV1EventSucceeded) })
113 cfg.OnFailed(func() { result = int(wlr.ZwlrOutputConfigurationV1EventFailed) })
114 cfg.OnCancelled(func() { result = int(wlr.ZwlrOutputConfigurationV1EventCancelled) })
115 // A configuration cancelled on creation accepts only its destructor.
116 if err := c.Roundtrip(); err != nil {
117 t.Fatal(err)
118 }
119 var child *client.OutputConfigurationHead
120 if result < 0 {
121 child = set(cfg)
122 if err := cfg.Apply(); err != nil {
123 t.Fatal(err)
124 }
125 roundtrip(func() bool { return result >= 0 })
126 }
127 // Configuration heads have no destructor request of their own.
128 if err := cfg.Destroy(); err != nil {
129 t.Fatal(err)
130 }
131 if child != nil {
132 c.Context().Unregister(child)
133 }
134 if result != int(wlr.ZwlrOutputConfigurationV1EventCancelled) {
135 return result
136 }
137 if serial == requested {
138 t.Fatalf("configuration cancelled without a newer inventory serial: %d", requested)
139 }
140 }
141 t.Fatal("output inventory kept changing across three configurations")
142 return -1
143 }
144 got := configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead {
145 ch, err := cfg.EnableHead(head)
146 if err != nil {
147 t.Fatal(err)
148 }
149 if err := ch.SetScale(wlturbo.NewFixed(2)); err != nil {
150 t.Fatal(err)
151 }
152 return ch
153 })
154 if got != int(wlr.ZwlrOutputConfigurationV1EventSucceeded) {
155 t.Fatalf("scale 2: event %d", got)
156 }
157 scene(func(s ports.Scene) bool { return s.Scale == 2 })
158 roundtrip(func() bool { return serial != initialSerial })
159 got = configure(func(cfg *client.OutputConfiguration) *client.OutputConfigurationHead {
160 if err := cfg.DisableHead(head); err != nil {
161 t.Fatal(err)
162 }
163 return nil
164 })
165 if got != int(wlr.ZwlrOutputConfigurationV1EventFailed) {
166 t.Fatalf("disable: event %d", got)
167 }
168}