1//go:build linux
2
3package systemtest
4
5import (
6 "context"
7 "encoding/json"
8 "fmt"
9 "image"
10 "image/color"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/bnema/nefergui/internal/harness"
19)
20
21func demoBinary(t *testing.T) string {
22 t.Helper()
23 bin := filepath.Join(t.TempDir(), "demo")
24 cmd := exec.Command("go", "build", "-o", bin, "./examples/demo")
25 cmd.Dir = "../../.."
26 cmd.Env = append(os.Environ(), "GOWORK=off", "CGO_ENABLED=0")
27 if out, err := cmd.CombinedOutput(); err != nil {
28 t.Fatalf("build demo: %v %s", err, out)
29 }
30 return bin
31}
32func runDemo(t *testing.T, bin string, scale, frames int, input string, minDuration time.Duration) harness.Result {
33 t.Helper()
34 root := filepath.Join(t.TempDir(), "artifacts")
35 client := []string{bin, "--static", "--frames", fmt.Sprint(frames), "--min-duration", minDuration.String()}
36 res, code := harness.Run(context.Background(), harness.Options{Size: fmt.Sprintf("%dx%d", 960*scale, 640*scale), Scale: fmt.Sprint(scale), Layout: "us", Background: background, Out: root, Input: input, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: client})
37 t.Logf("scale=%d frames=%d code=%d artifacts=%s", scale, frames, code, root)
38 if code != harness.Pass {
39 b, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
40 t.Fatalf("harness: %s stderr=%s", res.Error, b)
41 }
42 return res
43}
44func TestDemoStatic(t *testing.T) {
45 requireHarness(t)
46 bin := demoBinary(t)
47 for _, scale := range []int{1, 2} {
48 t.Run(fmt.Sprint(scale), func(t *testing.T) {
49 res := runDemo(t, bin, scale, 24, "", 0)
50 files, err := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
51 if err != nil || len(files) == 0 {
52 t.Fatalf("readback files: %v %v", files, err)
53 }
54 img, err := harness.LoadPNG(files[len(files)-1])
55 if err != nil {
56 t.Fatal(err)
57 }
58 if img.Bounds().Dx() != 960*scale || img.Bounds().Dy() != 640*scale {
59 t.Fatalf("size %v", img.Bounds())
60 }
61 for _, p := range []struct {
62 name string
63 x, y int
64 want color.NRGBA
65 }{
66 {"background", 5, 5, color.NRGBA{255, 255, 255, 255}},
67 {"button", 740, 185, color.NRGBA{212, 212, 216, 255}},
68 } {
69 x, y := p.x*scale, p.y*scale
70 t.Logf("%s (%d,%d)=%v", p.name, x, y, color.NRGBAModel.Convert(img.At(x, y)))
71 if err := harness.PixelProbe(img, x, y, p.want, 8); err != nil {
72 t.Error(err)
73 }
74 }
75 // Labels must reach the screen. Each region lies inside one control
76 // fill: most pixels must match it (the control is where expected) and
77 // enough must differ (glyph ink). Background probes alone once passed
78 // with every control label missing.
79 for _, l := range []struct {
80 name string
81 x0, y0, x1, y1 int
82 fill color.NRGBA
83 }{
84 {"Apply", 810, 177, 866, 198, color.NRGBA{212, 212, 216, 255}},
85 {"Untitled.txt", 24, 113, 115, 136, color.NRGBA{228, 228, 231, 255}},
86 {"Dark mode", 850, 14, 935, 35, color.NRGBA{255, 255, 255, 255}},
87 {"Name placeholder", 749, 130, 840, 152, color.NRGBA{250, 250, 250, 255}},
88 } {
89 n, total := ink(img, l.x0*scale, l.y0*scale, l.x1*scale, l.y1*scale, l.fill)
90 if n < 40*scale*scale || n > total/2 {
91 t.Errorf("%s: %d/%d ink pixels, label not painted in its control", l.name, n, total)
92 }
93 }
94 })
95 }
96}
97
98// ink counts pixels in [x0,x1)×[y0,y1) far from fill, and the region size.
99func ink(img image.Image, x0, y0, x1, y1 int, fill color.NRGBA) (n, total int) {
100 for y := y0; y < y1; y++ {
101 for x := x0; x < x1; x++ {
102 c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
103 if absDiff(c.R, fill.R)+absDiff(c.G, fill.G)+absDiff(c.B, fill.B) > 30 {
104 n++
105 }
106 total++
107 }
108 }
109 return n, total
110}
111func absDiff(a, b uint8) int {
112 if a > b {
113 return int(a - b)
114 }
115 return int(b - a)
116}
117
118// TestDemoCursorShapes hovers a menu button, the input and the background,
119// then reads the cursor shapes NeferWL applied from its run log (the harness
120// enables wayland debug logging). The shapes come from the demo's CSS: UA
121// `cursor: pointer` on buttons and `cursor: text` on inputs.
122func TestDemoCursorShapes(t *testing.T) {
123 requireHarness(t)
124 bin := demoBinary(t)
125 script := filepath.Join(t.TempDir(), "input.txt")
126 // Same cold-start allowance as TestDemoInputAndResources: the script
127 // starts at compositor launch, not when the demo maps.
128 if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 123\nsleep 1s\nmove 800 140\nsleep 1s\nmove 100 580\nsleep 1s\n"), 0600); err != nil {
129 t.Fatal(err)
130 }
131 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
132 data, err := os.ReadFile(res.Artifacts["compositor_run_log"])
133 if err != nil {
134 t.Fatal(err)
135 }
136 var shapes []string
137 for _, line := range strings.Split(string(data), "\n") {
138 var entry struct{ Message, Shape string }
139 if json.Unmarshal([]byte(line), &entry) == nil && entry.Message == "cursor" && entry.Shape != "" {
140 if len(shapes) == 0 || shapes[len(shapes)-1] != entry.Shape {
141 shapes = append(shapes, entry.Shape)
142 }
143 }
144 }
145 if got := strings.Join(shapes, ","); got != "pointer,text,default" {
146 t.Fatalf("cursor shapes %q, want pointer,text,default", got)
147 }
148}
149
150func TestDemoInputAndResources(t *testing.T) {
151 requireHarness(t)
152 bin := demoBinary(t)
153 script := filepath.Join(t.TempDir(), "input.txt")
154 // The compositor starts the script at launch, not when the client maps;
155 // wait past cold start (font discovery) before injecting input. The demo
156 // keeps presenting for 12 s so the run outlasts the ~10 s script however
157 // fast frames render.
158 if err := os.WriteFile(script, []byte("sleep 8s\nmove 800 140\nsleep 200ms\nclick left\nsleep 200ms\ntype Example\nsleep 200ms\nmove 820 186\nsleep 200ms\nclick left\nsleep 1s\n"), 0600); err != nil {
159 t.Fatal(err)
160 }
161 res := runDemo(t, bin, 1, 400, script, 12*time.Second)
162 data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
163 if err != nil {
164 t.Fatal(err)
165 }
166 var state struct{ Name, Status string }
167 if err = json.Unmarshal(data, &state); err != nil {
168 t.Fatal(err)
169 }
170 if state.Name != "Example" || state.Status != "Author set to Example · session only" {
171 layout, _ := os.ReadFile(filepath.Join(res.Artifacts["debug"], "layout.json"))
172 t.Fatalf("state %+v layout=%s", state, layout)
173 }
174 var s summary
175 data, err = os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
176 if err != nil {
177 t.Fatal(err)
178 }
179 if err = json.Unmarshal(data, &s); err != nil {
180 t.Fatal(err)
181 }
182 t.Logf("frames=%d fd_start=%d fd_peak=%d fd_after=%d images_after=%d", s.Frames, s.FDStart, s.FDPeak, s.FDAfter, s.ImagesAfter)
183 // FDStart > 0 proves the summary fields were actually decoded.
184 if s.Frames < 400 || s.FDStart == 0 || s.ImagesAfter != 0 || s.FDPeak-s.FDStart > 2 || s.FDAfter > s.FDStart {
185 t.Fatalf("resources %+v", s)
186 }
187}
188
189// TestDemoIndicators returns to the editor, selects the second radio and
190// clicks the slider track, then checks the model and the painted indicators
191// in the captured frame: the selected radio and the slider fill up to the
192// thumb use the accent color, the rest of the track does not.
193func TestDemoIndicators(t *testing.T) {
194 requireHarness(t)
195 bin := demoBinary(t)
196 script := filepath.Join(t.TempDir(), "input.txt")
197 // Same cold-start allowance as TestDemoInputAndResources.
198 if err := os.WriteFile(script, []byte("sleep 8s\nmove 80 166\nsleep 200ms\nclick left\nsleep 800ms\nmove 80 123\nsleep 200ms\nclick left\nsleep 500ms\nmove 800 312\nsleep 200ms\nclick left\nsleep 300ms\nmove 883 377\nclick left\nsleep 300ms\nmove 700 560\nsleep 1s\n"), 0600); err != nil {
199 t.Fatal(err)
200 }
201 res := runDemo(t, bin, 1, 400, script, 13*time.Second)
202 data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "demo-state.json"))
203 if err != nil {
204 t.Fatal(err)
205 }
206 var state struct {
207 Page, Density string
208 Volume float64
209 }
210 if err = json.Unmarshal(data, &state); err != nil {
211 t.Fatal(err)
212 }
213 if state.Page != "home" || state.Density != "compact" || state.Volume != 70 {
214 t.Fatalf("state %+v", state)
215 }
216 img := lastMappedFrame(t, res.Artifacts["frames"])
217 accent := color.NRGBA{82, 82, 91, 255} // light theme accent
218 // Muted paint is the demo text color at 45% alpha over the inspector. Each probe
219 // expects a specific color, so missing paint (plain white) fails too.
220 muted := color.NRGBA{151, 151, 152, 255}
221 for _, p := range []struct {
222 name string
223 x, y int
224 want color.NRGBA
225 }{
226 {"selected radio", 743, 304, accent},
227 {"unselected radio outline", 741, 273, muted},
228 {"slider fill", 790, 377, accent},
229 {"slider rail", 930, 377, muted},
230 } {
231 t.Logf("%s (%d,%d)=%v", p.name, p.x, p.y, color.NRGBAModel.Convert(img.At(p.x, p.y)))
232 if err := harness.PixelProbe(img, p.x, p.y, p.want, 12); err != nil {
233 t.Errorf("%s: %v", p.name, err)
234 }
235 }
236}
237
238// lastMappedFrame is the newest compositor frame still showing the demo: the
239// capture request fires before scripted input, and the final frames follow
240// client exit.
241func lastMappedFrame(t *testing.T, dir string) image.Image {
242 t.Helper()
243 files, err := filepath.Glob(filepath.Join(dir, "frame-*.png"))
244 if err != nil {
245 t.Fatal(err)
246 }
247 for i := len(files) - 1; i >= 0; i-- {
248 img, err := harness.LoadPNG(files[i])
249 if err != nil {
250 continue // possibly still being written
251 }
252 if harness.PixelProbe(img, 5, 5, color.NRGBA{255, 255, 255, 255}, 8) == nil || harness.PixelProbe(img, 5, 5, color.NRGBA{32, 32, 35, 255}, 8) == nil {
253 return img
254 }
255 }
256 t.Fatalf("no mapped demo frame in %s", dir)
257 return nil
258}