detached 7855cc6

Merge pull request #316 from bnema/backlog/testcov-headless-tests

test(ui): make headless gtk tests deterministic
commit
7855cc6df592a1076f461f51e957be6b061d6f97
author
bnema · · 2026-06-20 13:33 UTC
committer
GitHub · · 2026-06-20 13:33 UTC
merge of
c0d056e + 312aa52 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
1 file · +671 -27
M internal/ui/app_browser_launch_test.go +671 -27
@@ -1,10 +1,13 @@
11 package ui
22
33 import (
4+ "bytes"
45 "context"
6+ "encoding/binary"
57 "errors"
68 "io"
79 "math"
10+ "net"
811 "os"
912 "path/filepath"
1013 "reflect"
@@ -79,51 +82,220 @@ func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.Sessi
7982
8083 func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil }
8184
82-func testHasUsableGTKDisplay() bool {
83- if waylandDisplay := os.Getenv("WAYLAND_DISPLAY"); waylandDisplay != "" {
84- candidates := []string{waylandDisplay}
85- if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {
86- candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)
85+func testPathIsSocket(path string) bool {
86+ info, err := os.Stat(path)
87+ return err == nil && info.Mode()&os.ModeSocket != 0
88+}
89+
90+func testGDKBackendAllows(backend string) bool {
91+ configured := os.Getenv("GDK_BACKEND")
92+ if configured == "" {
93+ return true
94+ }
95+ for _, candidate := range strings.Split(configured, ",") {
96+ if strings.TrimSpace(candidate) == backend {
97+ return true
8798 }
88- for _, candidate := range candidates {
89- if _, err := os.Stat(candidate); err == nil {
99+ }
100+ return false
101+}
102+
103+func testHasUsableWaylandDisplay() bool {
104+ if !testGDKBackendAllows("wayland") {
105+ return false
106+ }
107+ waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
108+ if waylandDisplay == "" {
109+ return false
110+ }
111+ candidates := []string{waylandDisplay}
112+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {
113+ candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)
114+ }
115+ for _, candidate := range candidates {
116+ if testPathIsSocket(candidate) {
117+ return true
118+ }
119+ }
120+ return false
121+}
122+
123+func testHasUsableX11Display() bool {
124+ if !testGDKBackendAllows("x11") {
125+ return false
126+ }
127+ display := os.Getenv("DISPLAY")
128+ if display == "" {
129+ return false
130+ }
131+ if strings.HasPrefix(display, ":") {
132+ displayNum := strings.TrimPrefix(display, ":")
133+ displayNum = strings.SplitN(displayNum, ".", 2)[0]
134+ if displayNum == "" {
135+ return false
136+ }
137+ x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}
138+ if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {
139+ x11SocketCandidates = append(x11SocketCandidates, fallback)
140+ }
141+ for _, candidate := range x11SocketCandidates {
142+ if testPathIsSocket(candidate) {
90143 return true
91144 }
92145 }
146+ return false
93147 }
148+ return true // TCP display
149+}
94150
95- if display := os.Getenv("DISPLAY"); display != "" {
96- if strings.HasPrefix(display, ":") {
97- displayNum := strings.TrimPrefix(display, ":")
98- displayNum = strings.SplitN(displayNum, ".", 2)[0]
99- if displayNum == "" {
100- return false
101- }
102- x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}
103- if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {
104- x11SocketCandidates = append(x11SocketCandidates, fallback)
105- }
106- for _, candidate := range x11SocketCandidates {
107- if _, err := os.Stat(candidate); err == nil {
108- return true
109- }
110- }
151+func testHasUsableGTKDisplay() bool {
152+ return testHasUsableWaylandDisplay() || testHasUsableX11Display()
153+}
154+
155+// testHasX11Auth checks whether X11 authorization is available for the current
156+// DISPLAY before any GTK/Adwaita initialization. It is a pure function that
157+// inspects env vars and the .Xauthority file only; no GTK calls.
158+func testHasX11Auth() bool {
159+ display := os.Getenv("DISPLAY")
160+ if display == "" || !strings.HasPrefix(display, ":") {
161+ // No DISPLAY or TCP-style display; can't check locally.
162+ // Let GTK try and fall through to gdk.DisplayGetDefault().
163+ return true
164+ }
165+
166+ displayNum := strings.TrimPrefix(display, ":")
167+ displayNum = strings.SplitN(displayNum, ".", 2)[0]
168+ if displayNum == "" {
169+ return false
170+ }
171+
172+ // Find the X authority file.
173+ authFile := os.Getenv("XAUTHORITY")
174+ if authFile == "" {
175+ home, err := os.UserHomeDir()
176+ if err != nil {
111177 return false
112178 }
113- return true
179+ authFile = filepath.Join(home, ".Xauthority")
180+ }
181+
182+ if _, err := os.Stat(authFile); err != nil {
183+ return false // No authority file; X11 auth will fail.
184+ }
185+
186+ // Parse the .Xauthority file to look for a matching display entry.
187+ // Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data}
188+ // All multi-byte values are big-endian (network byte order).
189+ data, err := os.ReadFile(authFile)
190+ if err != nil {
191+ return false
114192 }
115193
194+ return testXauthorityHasDisplay(data, displayNum)
195+}
196+
197+func testXauthorityHasDisplay(data []byte, displayNum string) bool {
198+ for len(data) > 0 {
199+ if len(data) < 2 {
200+ return false
201+ }
202+ data = data[2:] // family, unused
203+
204+ _, rest, ok := testReadXauthorityField(data) // address
205+ if !ok {
206+ return false
207+ }
208+ num, rest, ok := testReadXauthorityField(rest)
209+ if !ok {
210+ return false
211+ }
212+ name, rest, ok := testReadXauthorityField(rest)
213+ if !ok {
214+ return false
215+ }
216+ cookie, rest, ok := testReadXauthorityField(rest)
217+ if !ok {
218+ return false
219+ }
220+ if string(num) == displayNum && len(name) > 0 && len(cookie) > 0 {
221+ return true
222+ }
223+ data = rest
224+ }
116225 return false
117226 }
118227
228+func testReadXauthorityField(data []byte) ([]byte, []byte, bool) {
229+ if len(data) < 2 {
230+ return nil, nil, false
231+ }
232+ fieldLen := int(binary.BigEndian.Uint16(data[:2]))
233+ data = data[2:]
234+ if len(data) < fieldLen {
235+ return nil, nil, false
236+ }
237+ return data[:fieldLen], data[fieldLen:], true
238+}
239+
240+// testNeedsX11Auth reports whether GTK may need the X11 backend and therefore
241+// requires X11 authorization before GTK initialization. Wayland-only backend
242+// configuration never needs X11 auth; X11 is required when it is the only usable
243+// allowed backend.
244+func testNeedsX11Auth() bool {
245+ if !testGDKBackendAllows("x11") {
246+ return false
247+ }
248+ return !testGDKBackendAllows("wayland") || !testHasUsableWaylandDisplay()
249+}
250+
251+func testCreateUnixSocket(t *testing.T, path string) {
252+ t.Helper()
253+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
254+ t.Fatal(err)
255+ }
256+ listener, err := net.Listen("unix", path)
257+ if err != nil {
258+ t.Fatal(err)
259+ }
260+ t.Cleanup(func() {
261+ _ = listener.Close()
262+ _ = os.Remove(path)
263+ })
264+}
265+
266+func testUnusedX11DisplayNumber(t *testing.T) string {
267+ t.Helper()
268+ for display := 90; display < 300; display++ {
269+ displayNum := strconv.Itoa(display)
270+ if !testPathIsSocket("/tmp/.X11-unix/X" + displayNum) {
271+ return displayNum
272+ }
273+ }
274+ t.Fatal("could not find an unused X11 display number")
275+ return ""
276+}
277+
278+func testCreateX11Socket(t *testing.T, displayNum string) {
279+ t.Helper()
280+ tmpDir := t.TempDir()
281+ t.Setenv("TMPDIR", tmpDir)
282+ testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
283+}
284+
119285 func requireGTKDisplayApp(t *testing.T) *gtk.Application {
120286 t.Helper()
121- if !testHasUsableGTKDisplay() {
122- t.Skip("GTK display not available")
287+ hasWayland := testHasUsableWaylandDisplay()
288+ hasX11 := testHasUsableX11Display()
289+ if !hasWayland && !hasX11 {
290+ t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)")
291+ }
292+ // Only require X11 auth when X11 is the only usable allowed backend.
293+ if testNeedsX11Auth() && !testHasX11Auth() {
294+ t.Skip("GTK display not available (X11 authorization unavailable)")
123295 }
124296 EnsureAdwaitaInitialized()
125297 if gdk.DisplayGetDefault() == nil {
126- t.Skip("GTK display not available")
298+ t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)")
127299 }
128300 appID := AppID
129301 gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
@@ -133,6 +305,478 @@ func requireGTKDisplayApp(t *testing.T) *gtk.Application {
133305 return gtkApp
134306 }
135307
308+func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) {
309+ t.Setenv("GDK_BACKEND", "")
310+
311+ t.Run("no display env vars", func(t *testing.T) {
312+ t.Setenv("DISPLAY", "")
313+ t.Setenv("WAYLAND_DISPLAY", "")
314+ if got := testHasUsableGTKDisplay(); got {
315+ t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
316+ }
317+ })
318+
319+ t.Run("DISPLAY numeric without socket", func(t *testing.T) {
320+ displayNum := testUnusedX11DisplayNumber(t)
321+ t.Setenv("DISPLAY", ":"+displayNum)
322+ t.Setenv("WAYLAND_DISPLAY", "")
323+ if got := testHasUsableGTKDisplay(); got {
324+ t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
325+ }
326+ })
327+
328+ t.Run("DISPLAY with screen without socket", func(t *testing.T) {
329+ displayNum := testUnusedX11DisplayNumber(t)
330+ t.Setenv("DISPLAY", ":"+displayNum+".0")
331+ t.Setenv("WAYLAND_DISPLAY", "")
332+ if got := testHasUsableGTKDisplay(); got {
333+ t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
334+ }
335+ })
336+
337+ t.Run("DISPLAY colon only no number", func(t *testing.T) {
338+ t.Setenv("DISPLAY", ":")
339+ t.Setenv("WAYLAND_DISPLAY", "")
340+ if got := testHasUsableGTKDisplay(); got {
341+ t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got)
342+ }
343+ })
344+
345+ t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
346+ t.Setenv("DISPLAY", "remote-host:0")
347+ t.Setenv("WAYLAND_DISPLAY", "")
348+ if got := testHasUsableGTKDisplay(); !got {
349+ t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got)
350+ }
351+ })
352+
353+ t.Run("DISPLAY with existing socket", func(t *testing.T) {
354+ displayNum := testUnusedX11DisplayNumber(t)
355+ tmpDir := t.TempDir()
356+ t.Setenv("TMPDIR", tmpDir)
357+ t.Setenv("DISPLAY", ":"+displayNum)
358+ t.Setenv("WAYLAND_DISPLAY", "")
359+ testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
360+ if got := testHasUsableGTKDisplay(); !got {
361+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing X11 socket", got)
362+ }
363+ })
364+
365+ t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
366+ t.Setenv("DISPLAY", "")
367+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
368+ tmpDir := t.TempDir()
369+ socketPath := filepath.Join(tmpDir, "wayland-1")
370+ testCreateUnixSocket(t, socketPath)
371+ // XDG_RUNTIME_DIR controls where Wayland sockets are looked up
372+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
373+ if got := testHasUsableGTKDisplay(); !got {
374+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got)
375+ }
376+ })
377+
378+ t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) {
379+ t.Setenv("DISPLAY", "")
380+ t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent")
381+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
382+ if got := testHasUsableGTKDisplay(); got {
383+ t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got)
384+ }
385+ })
386+
387+ t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) {
388+ t.Setenv("DISPLAY", "")
389+ socketPath := filepath.Join(t.TempDir(), "wayland.sock")
390+ testCreateUnixSocket(t, socketPath)
391+ t.Setenv("WAYLAND_DISPLAY", socketPath)
392+ t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths
393+ if got := testHasUsableGTKDisplay(); !got {
394+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got)
395+ }
396+ })
397+}
398+
399+func Test_testHasX11Auth_PureProbe(t *testing.T) {
400+ t.Run("DISPLAY unset", func(t *testing.T) {
401+ t.Setenv("DISPLAY", "")
402+ t.Setenv("XAUTHORITY", "")
403+ if got := testHasX11Auth(); !got {
404+ t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got)
405+ }
406+ })
407+
408+ t.Run("DISPLAY TCP remote", func(t *testing.T) {
409+ t.Setenv("DISPLAY", "remote-host:0")
410+ t.Setenv("XAUTHORITY", "")
411+ if got := testHasX11Auth(); !got {
412+ t.Errorf("testHasX11Auth() = %v, want true for TCP display", got)
413+ }
414+ })
415+
416+ t.Run("DISPLAY local no auth file", func(t *testing.T) {
417+ t.Setenv("DISPLAY", ":0")
418+ t.Setenv("XAUTHORITY", "")
419+ // Use a temp home dir with no .Xauthority
420+ t.Setenv("HOME", t.TempDir())
421+ if got := testHasX11Auth(); got {
422+ t.Errorf("testHasX11Auth() = %v, want false without auth file", got)
423+ }
424+ })
425+
426+ t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) {
427+ t.Setenv("DISPLAY", ":0")
428+ t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority")
429+ if got := testHasX11Auth(); got {
430+ t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got)
431+ }
432+ })
433+
434+ t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) {
435+ t.Setenv("DISPLAY", ":1")
436+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
437+ createXauthFile(t, authFile, "1", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
438+ t.Setenv("XAUTHORITY", authFile)
439+ if got := testHasX11Auth(); !got {
440+ t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got)
441+ }
442+ })
443+
444+ t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) {
445+ t.Setenv("DISPLAY", ":2")
446+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
447+ createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
448+ t.Setenv("XAUTHORITY", authFile)
449+ if got := testHasX11Auth(); got {
450+ t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got)
451+ }
452+ })
453+
454+ t.Run("DISPLAY local with screen number", func(t *testing.T) {
455+ t.Setenv("DISPLAY", ":0.0")
456+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
457+ createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
458+ t.Setenv("XAUTHORITY", authFile)
459+ if got := testHasX11Auth(); !got {
460+ t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got)
461+ }
462+ })
463+
464+ t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) {
465+ t.Setenv("DISPLAY", ":")
466+ t.Setenv("XAUTHORITY", "")
467+ if got := testHasX11Auth(); got {
468+ t.Errorf("testHasX11Auth() = %v, want false for empty display number", got)
469+ }
470+ })
471+}
472+
473+// writeXauthRecord appends one local-family .Xauthority record to buf with the given fields.
474+// If name or data is empty, the length byte is written as zero but the field
475+// data is omitted, producing a truncated record suitable for malformed tests.
476+func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) {
477+ // family (2 bytes, big-endian)
478+ familyBytes := make([]byte, 2)
479+ binary.BigEndian.PutUint16(familyBytes, 256)
480+ buf.Write(familyBytes)
481+
482+ // address
483+ addrLen := make([]byte, 2)
484+ addr := "host"
485+ binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
486+ buf.Write(addrLen)
487+ buf.WriteString(addr)
488+
489+ // number
490+ numLen := make([]byte, 2)
491+ binary.BigEndian.PutUint16(numLen, uint16(len(num)))
492+ buf.Write(numLen)
493+ buf.WriteString(num)
494+
495+ // name
496+ nameLen := make([]byte, 2)
497+ binary.BigEndian.PutUint16(nameLen, uint16(len(name)))
498+ buf.Write(nameLen)
499+ if name != "" {
500+ buf.WriteString(name)
501+ }
502+
503+ // data
504+ dLen := make([]byte, 2)
505+ binary.BigEndian.PutUint16(dLen, uint16(len(data)))
506+ buf.Write(dLen)
507+ if len(data) > 0 {
508+ buf.Write(data)
509+ }
510+}
511+
512+func Test_testHasX11Auth_MalformedRecords(t *testing.T) {
513+ t.Run("matching display with empty name field", func(t *testing.T) {
514+ t.Setenv("DISPLAY", ":1")
515+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
516+ var buf bytes.Buffer
517+ writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04})
518+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
519+ t.Fatal(err)
520+ }
521+ t.Setenv("XAUTHORITY", authFile)
522+ if got := testHasX11Auth(); got {
523+ t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got)
524+ }
525+ })
526+
527+ t.Run("matching display with empty data field", func(t *testing.T) {
528+ t.Setenv("DISPLAY", ":1")
529+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
530+ var buf bytes.Buffer
531+ writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil)
532+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
533+ t.Fatal(err)
534+ }
535+ t.Setenv("XAUTHORITY", authFile)
536+ if got := testHasX11Auth(); got {
537+ t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got)
538+ }
539+ })
540+
541+ t.Run("truncated record after display number", func(t *testing.T) {
542+ t.Setenv("DISPLAY", ":1")
543+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
544+ var buf bytes.Buffer
545+ family := make([]byte, 2)
546+ binary.BigEndian.PutUint16(family, 256)
547+ buf.Write(family)
548+ addr := "host"
549+ addrLen := make([]byte, 2)
550+ binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
551+ buf.Write(addrLen)
552+ buf.WriteString(addr)
553+ num := "1"
554+ numLen := make([]byte, 2)
555+ binary.BigEndian.PutUint16(numLen, uint16(len(num)))
556+ buf.Write(numLen)
557+ buf.WriteString(num)
558+ // Record ends here — no name or data fields follow.
559+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
560+ t.Fatal(err)
561+ }
562+ t.Setenv("XAUTHORITY", authFile)
563+ if got := testHasX11Auth(); got {
564+ t.Errorf("testHasX11Auth() = %v, want false for truncated record", got)
565+ }
566+ })
567+
568+ t.Run("multiple entries with one matching and valid", func(t *testing.T) {
569+ t.Setenv("DISPLAY", ":3")
570+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
571+ var buf bytes.Buffer
572+ writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04})
573+ writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08})
574+ writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
575+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
576+ t.Fatal(err)
577+ }
578+ t.Setenv("XAUTHORITY", authFile)
579+ if got := testHasX11Auth(); !got {
580+ t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got)
581+ }
582+ })
583+
584+ t.Run("invalid matching entry does not hide later valid match", func(t *testing.T) {
585+ t.Setenv("DISPLAY", ":3")
586+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
587+ var buf bytes.Buffer
588+ writeXauthRecord(&buf, "3", "", []byte{0x01, 0x02, 0x03, 0x04})
589+ writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
590+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
591+ t.Fatal(err)
592+ }
593+ t.Setenv("XAUTHORITY", authFile)
594+ if got := testHasX11Auth(); !got {
595+ t.Errorf("testHasX11Auth() = %v, want true for later valid matching display", got)
596+ }
597+ })
598+}
599+
600+func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) {
601+ t.Setenv("GDK_BACKEND", "")
602+
603+ t.Run("no WAYLAND_DISPLAY", func(t *testing.T) {
604+ t.Setenv("WAYLAND_DISPLAY", "")
605+ if got := testHasUsableWaylandDisplay(); got {
606+ t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got)
607+ }
608+ })
609+
610+ t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
611+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
612+ tmpDir := t.TempDir()
613+ socketPath := filepath.Join(tmpDir, "wayland-1")
614+ testCreateUnixSocket(t, socketPath)
615+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
616+ if got := testHasUsableWaylandDisplay(); !got {
617+ t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got)
618+ }
619+ })
620+
621+ t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) {
622+ socketPath := filepath.Join(t.TempDir(), "wayland.sock")
623+ testCreateUnixSocket(t, socketPath)
624+ t.Setenv("WAYLAND_DISPLAY", socketPath)
625+ t.Setenv("XDG_RUNTIME_DIR", "/nonexistent")
626+ if got := testHasUsableWaylandDisplay(); !got {
627+ t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got)
628+ }
629+ })
630+}
631+
632+func Test_testHasUsableX11Display_PureProbe(t *testing.T) {
633+ t.Setenv("GDK_BACKEND", "")
634+
635+ t.Run("no DISPLAY", func(t *testing.T) {
636+ t.Setenv("DISPLAY", "")
637+ if got := testHasUsableX11Display(); got {
638+ t.Errorf("testHasUsableX11Display() = %v, want false", got)
639+ }
640+ })
641+
642+ t.Run("DISPLAY numeric without socket", func(t *testing.T) {
643+ displayNum := testUnusedX11DisplayNumber(t)
644+ t.Setenv("DISPLAY", ":"+displayNum)
645+ if got := testHasUsableX11Display(); got {
646+ t.Errorf("testHasUsableX11Display() = %v, want false", got)
647+ }
648+ })
649+
650+ t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
651+ t.Setenv("DISPLAY", "remote-host:0")
652+ if got := testHasUsableX11Display(); !got {
653+ t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got)
654+ }
655+ })
656+}
657+
658+func Test_testHasUsableGTKDisplay_GDKBackendSelection(t *testing.T) {
659+ t.Run("GDK_BACKEND=x11 ignores Wayland-only socket", func(t *testing.T) {
660+ t.Setenv("GDK_BACKEND", "x11")
661+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
662+ tmpDir := t.TempDir()
663+ testCreateUnixSocket(t, filepath.Join(tmpDir, "wayland-1"))
664+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
665+ t.Setenv("DISPLAY", ":"+testUnusedX11DisplayNumber(t))
666+ if got := testHasUsableGTKDisplay(); got {
667+ t.Errorf("testHasUsableGTKDisplay() = %v, want false when x11 is forced without an X11 socket", got)
668+ }
669+ })
670+
671+ t.Run("GDK_BACKEND=wayland ignores X11-only socket", func(t *testing.T) {
672+ displayNum := testUnusedX11DisplayNumber(t)
673+ t.Setenv("GDK_BACKEND", "wayland")
674+ t.Setenv("DISPLAY", ":"+displayNum)
675+ t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
676+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
677+ testCreateX11Socket(t, displayNum)
678+ if got := testHasUsableGTKDisplay(); got {
679+ t.Errorf("testHasUsableGTKDisplay() = %v, want false when wayland is forced without a Wayland socket", got)
680+ }
681+ })
682+
683+ t.Run("GDK_BACKEND=wayland,x11 allows X11 fallback", func(t *testing.T) {
684+ displayNum := testUnusedX11DisplayNumber(t)
685+ t.Setenv("GDK_BACKEND", "wayland,x11")
686+ t.Setenv("DISPLAY", ":"+displayNum)
687+ t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
688+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
689+ testCreateX11Socket(t, displayNum)
690+ if got := testHasUsableGTKDisplay(); !got {
691+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with X11 fallback socket", got)
692+ }
693+ if got := testNeedsX11Auth(); !got {
694+ t.Errorf("testNeedsX11Auth() = %v, want true when X11 is the only usable backend", got)
695+ }
696+ })
697+}
698+
699+func Test_testNeedsX11Auth_PureProbe(t *testing.T) {
700+ t.Run("Wayland available forces no X11 auth", func(t *testing.T) {
701+ t.Setenv("GDK_BACKEND", "")
702+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
703+ tmpDir := t.TempDir()
704+ socketPath := filepath.Join(tmpDir, "wayland-1")
705+ testCreateUnixSocket(t, socketPath)
706+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
707+ t.Setenv("DISPLAY", ":0")
708+ if got := testNeedsX11Auth(); got {
709+ t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got)
710+ }
711+ })
712+
713+ t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) {
714+ t.Setenv("GDK_BACKEND", "x11")
715+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
716+ tmpDir := t.TempDir()
717+ socketPath := filepath.Join(tmpDir, "wayland-1")
718+ testCreateUnixSocket(t, socketPath)
719+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
720+ if got := testNeedsX11Auth(); !got {
721+ t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got)
722+ }
723+ })
724+
725+ t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) {
726+ t.Setenv("GDK_BACKEND", "")
727+ t.Setenv("WAYLAND_DISPLAY", "")
728+ t.Setenv("DISPLAY", ":0")
729+ if got := testNeedsX11Auth(); !got {
730+ t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got)
731+ }
732+ })
733+}
734+
735+// createXauthFile writes a minimal .Xauthority file with one entry.
736+// family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie.
737+func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) {
738+ t.Helper()
739+
740+ hostname, err := os.Hostname()
741+ if err != nil {
742+ hostname = "localhost"
743+ }
744+
745+ var buf bytes.Buffer
746+ // family (2 bytes, big-endian) - 256 = FamilyLocal
747+ family := make([]byte, 2)
748+ binary.BigEndian.PutUint16(family, 256)
749+ buf.Write(family)
750+
751+ // address length + address (hostname)
752+ addrLen := make([]byte, 2)
753+ binary.BigEndian.PutUint16(addrLen, uint16(len(hostname)))
754+ buf.Write(addrLen)
755+ buf.WriteString(hostname)
756+
757+ // number length + number (display number)
758+ numLen := make([]byte, 2)
759+ binary.BigEndian.PutUint16(numLen, uint16(len(displayNum)))
760+ buf.Write(numLen)
761+ buf.WriteString(displayNum)
762+
763+ // name length + name (auth protocol)
764+ nameLen := make([]byte, 2)
765+ binary.BigEndian.PutUint16(nameLen, uint16(len(proto)))
766+ buf.Write(nameLen)
767+ buf.WriteString(proto)
768+
769+ // data length + data (auth cookie)
770+ dataLen := make([]byte, 2)
771+ binary.BigEndian.PutUint16(dataLen, uint16(len(cookie)))
772+ buf.Write(dataLen)
773+ buf.Write(cookie)
774+
775+ if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
776+ t.Fatal(err)
777+ }
778+}
779+
136780 // setDependencyField uses reflection to reach unexported dependency fields for tests.
137781 // Keep this test-only so production visibility stays narrow.
138782 func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
@@ -1,10 +1,13 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
6+ "encoding/binary"
5 "errors"7 "errors"
6 "io"8 "io"
7 "math"9 "math"
10+ "net"
8 "os"11 "os"
9 "path/filepath"12 "path/filepath"
10 "reflect"13 "reflect"
@@ -79,51 +82,220 @@ func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.Sessi
79 82
80 func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil }83 func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil }
81 84
82-func testHasUsableGTKDisplay() bool {85+func testPathIsSocket(path string) bool {
83- if waylandDisplay := os.Getenv("WAYLAND_DISPLAY"); waylandDisplay != "" {86+ info, err := os.Stat(path)
84- candidates := []string{waylandDisplay}87+ return err == nil && info.Mode()&os.ModeSocket != 0
85- if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {88+}
86- candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)89+
90+func testGDKBackendAllows(backend string) bool {
91+ configured := os.Getenv("GDK_BACKEND")
92+ if configured == "" {
93+ return true
94+ }
95+ for _, candidate := range strings.Split(configured, ",") {
96+ if strings.TrimSpace(candidate) == backend {
97+ return true
87 }98 }
88- for _, candidate := range candidates {99+ }
89- if _, err := os.Stat(candidate); err == nil {100+ return false
101+}
102+
103+func testHasUsableWaylandDisplay() bool {
104+ if !testGDKBackendAllows("wayland") {
105+ return false
106+ }
107+ waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
108+ if waylandDisplay == "" {
109+ return false
110+ }
111+ candidates := []string{waylandDisplay}
112+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {
113+ candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)
114+ }
115+ for _, candidate := range candidates {
116+ if testPathIsSocket(candidate) {
117+ return true
118+ }
119+ }
120+ return false
121+}
122+
123+func testHasUsableX11Display() bool {
124+ if !testGDKBackendAllows("x11") {
125+ return false
126+ }
127+ display := os.Getenv("DISPLAY")
128+ if display == "" {
129+ return false
130+ }
131+ if strings.HasPrefix(display, ":") {
132+ displayNum := strings.TrimPrefix(display, ":")
133+ displayNum = strings.SplitN(displayNum, ".", 2)[0]
134+ if displayNum == "" {
135+ return false
136+ }
137+ x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}
138+ if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {
139+ x11SocketCandidates = append(x11SocketCandidates, fallback)
140+ }
141+ for _, candidate := range x11SocketCandidates {
142+ if testPathIsSocket(candidate) {
90 return true143 return true
91 }144 }
92 }145 }
146+ return false
93 }147 }
148+ return true // TCP display
149+}
94 150
95- if display := os.Getenv("DISPLAY"); display != "" {151+func testHasUsableGTKDisplay() bool {
96- if strings.HasPrefix(display, ":") {152+ return testHasUsableWaylandDisplay() || testHasUsableX11Display()
97- displayNum := strings.TrimPrefix(display, ":")153+}
98- displayNum = strings.SplitN(displayNum, ".", 2)[0]154+
99- if displayNum == "" {155+// testHasX11Auth checks whether X11 authorization is available for the current
100- return false156+// DISPLAY before any GTK/Adwaita initialization. It is a pure function that
101- }157+// inspects env vars and the .Xauthority file only; no GTK calls.
102- x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}158+func testHasX11Auth() bool {
103- if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {159+ display := os.Getenv("DISPLAY")
104- x11SocketCandidates = append(x11SocketCandidates, fallback)160+ if display == "" || !strings.HasPrefix(display, ":") {
105- }161+ // No DISPLAY or TCP-style display; can't check locally.
106- for _, candidate := range x11SocketCandidates {162+ // Let GTK try and fall through to gdk.DisplayGetDefault().
107- if _, err := os.Stat(candidate); err == nil {163+ return true
108- return true164+ }
109- }165+
110- }166+ displayNum := strings.TrimPrefix(display, ":")
167+ displayNum = strings.SplitN(displayNum, ".", 2)[0]
168+ if displayNum == "" {
169+ return false
170+ }
171+
172+ // Find the X authority file.
173+ authFile := os.Getenv("XAUTHORITY")
174+ if authFile == "" {
175+ home, err := os.UserHomeDir()
176+ if err != nil {
111 return false177 return false
112 }178 }
113- return true179+ authFile = filepath.Join(home, ".Xauthority")
180+ }
181+
182+ if _, err := os.Stat(authFile); err != nil {
183+ return false // No authority file; X11 auth will fail.
184+ }
185+
186+ // Parse the .Xauthority file to look for a matching display entry.
187+ // Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data}
188+ // All multi-byte values are big-endian (network byte order).
189+ data, err := os.ReadFile(authFile)
190+ if err != nil {
191+ return false
114 }192 }
115 193
194+ return testXauthorityHasDisplay(data, displayNum)
195+}
196+
197+func testXauthorityHasDisplay(data []byte, displayNum string) bool {
198+ for len(data) > 0 {
199+ if len(data) < 2 {
200+ return false
201+ }
202+ data = data[2:] // family, unused
203+
204+ _, rest, ok := testReadXauthorityField(data) // address
205+ if !ok {
206+ return false
207+ }
208+ num, rest, ok := testReadXauthorityField(rest)
209+ if !ok {
210+ return false
211+ }
212+ name, rest, ok := testReadXauthorityField(rest)
213+ if !ok {
214+ return false
215+ }
216+ cookie, rest, ok := testReadXauthorityField(rest)
217+ if !ok {
218+ return false
219+ }
220+ if string(num) == displayNum && len(name) > 0 && len(cookie) > 0 {
221+ return true
222+ }
223+ data = rest
224+ }
116 return false225 return false
117 }226 }
118 227
228+func testReadXauthorityField(data []byte) ([]byte, []byte, bool) {
229+ if len(data) < 2 {
230+ return nil, nil, false
231+ }
232+ fieldLen := int(binary.BigEndian.Uint16(data[:2]))
233+ data = data[2:]
234+ if len(data) < fieldLen {
235+ return nil, nil, false
236+ }
237+ return data[:fieldLen], data[fieldLen:], true
238+}
239+
240+// testNeedsX11Auth reports whether GTK may need the X11 backend and therefore
241+// requires X11 authorization before GTK initialization. Wayland-only backend
242+// configuration never needs X11 auth; X11 is required when it is the only usable
243+// allowed backend.
244+func testNeedsX11Auth() bool {
245+ if !testGDKBackendAllows("x11") {
246+ return false
247+ }
248+ return !testGDKBackendAllows("wayland") || !testHasUsableWaylandDisplay()
249+}
250+
251+func testCreateUnixSocket(t *testing.T, path string) {
252+ t.Helper()
253+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
254+ t.Fatal(err)
255+ }
256+ listener, err := net.Listen("unix", path)
257+ if err != nil {
258+ t.Fatal(err)
259+ }
260+ t.Cleanup(func() {
261+ _ = listener.Close()
262+ _ = os.Remove(path)
263+ })
264+}
265+
266+func testUnusedX11DisplayNumber(t *testing.T) string {
267+ t.Helper()
268+ for display := 90; display < 300; display++ {
269+ displayNum := strconv.Itoa(display)
270+ if !testPathIsSocket("/tmp/.X11-unix/X" + displayNum) {
271+ return displayNum
272+ }
273+ }
274+ t.Fatal("could not find an unused X11 display number")
275+ return ""
276+}
277+
278+func testCreateX11Socket(t *testing.T, displayNum string) {
279+ t.Helper()
280+ tmpDir := t.TempDir()
281+ t.Setenv("TMPDIR", tmpDir)
282+ testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
283+}
284+
119 func requireGTKDisplayApp(t *testing.T) *gtk.Application {285 func requireGTKDisplayApp(t *testing.T) *gtk.Application {
120 t.Helper()286 t.Helper()
121- if !testHasUsableGTKDisplay() {287+ hasWayland := testHasUsableWaylandDisplay()
122- t.Skip("GTK display not available")288+ hasX11 := testHasUsableX11Display()
289+ if !hasWayland && !hasX11 {
290+ t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)")
291+ }
292+ // Only require X11 auth when X11 is the only usable allowed backend.
293+ if testNeedsX11Auth() && !testHasX11Auth() {
294+ t.Skip("GTK display not available (X11 authorization unavailable)")
123 }295 }
124 EnsureAdwaitaInitialized()296 EnsureAdwaitaInitialized()
125 if gdk.DisplayGetDefault() == nil {297 if gdk.DisplayGetDefault() == nil {
126- t.Skip("GTK display not available")298+ t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)")
127 }299 }
128 appID := AppID300 appID := AppID
129 gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)301 gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
@@ -133,6 +305,478 @@ func requireGTKDisplayApp(t *testing.T) *gtk.Application {
133 return gtkApp305 return gtkApp
134 }306 }
135 307
308+func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) {
309+ t.Setenv("GDK_BACKEND", "")
310+
311+ t.Run("no display env vars", func(t *testing.T) {
312+ t.Setenv("DISPLAY", "")
313+ t.Setenv("WAYLAND_DISPLAY", "")
314+ if got := testHasUsableGTKDisplay(); got {
315+ t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
316+ }
317+ })
318+
319+ t.Run("DISPLAY numeric without socket", func(t *testing.T) {
320+ displayNum := testUnusedX11DisplayNumber(t)
321+ t.Setenv("DISPLAY", ":"+displayNum)
322+ t.Setenv("WAYLAND_DISPLAY", "")
323+ if got := testHasUsableGTKDisplay(); got {
324+ t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
325+ }
326+ })
327+
328+ t.Run("DISPLAY with screen without socket", func(t *testing.T) {
329+ displayNum := testUnusedX11DisplayNumber(t)
330+ t.Setenv("DISPLAY", ":"+displayNum+".0")
331+ t.Setenv("WAYLAND_DISPLAY", "")
332+ if got := testHasUsableGTKDisplay(); got {
333+ t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
334+ }
335+ })
336+
337+ t.Run("DISPLAY colon only no number", func(t *testing.T) {
338+ t.Setenv("DISPLAY", ":")
339+ t.Setenv("WAYLAND_DISPLAY", "")
340+ if got := testHasUsableGTKDisplay(); got {
341+ t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got)
342+ }
343+ })
344+
345+ t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
346+ t.Setenv("DISPLAY", "remote-host:0")
347+ t.Setenv("WAYLAND_DISPLAY", "")
348+ if got := testHasUsableGTKDisplay(); !got {
349+ t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got)
350+ }
351+ })
352+
353+ t.Run("DISPLAY with existing socket", func(t *testing.T) {
354+ displayNum := testUnusedX11DisplayNumber(t)
355+ tmpDir := t.TempDir()
356+ t.Setenv("TMPDIR", tmpDir)
357+ t.Setenv("DISPLAY", ":"+displayNum)
358+ t.Setenv("WAYLAND_DISPLAY", "")
359+ testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
360+ if got := testHasUsableGTKDisplay(); !got {
361+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing X11 socket", got)
362+ }
363+ })
364+
365+ t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
366+ t.Setenv("DISPLAY", "")
367+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
368+ tmpDir := t.TempDir()
369+ socketPath := filepath.Join(tmpDir, "wayland-1")
370+ testCreateUnixSocket(t, socketPath)
371+ // XDG_RUNTIME_DIR controls where Wayland sockets are looked up
372+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
373+ if got := testHasUsableGTKDisplay(); !got {
374+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got)
375+ }
376+ })
377+
378+ t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) {
379+ t.Setenv("DISPLAY", "")
380+ t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent")
381+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
382+ if got := testHasUsableGTKDisplay(); got {
383+ t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got)
384+ }
385+ })
386+
387+ t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) {
388+ t.Setenv("DISPLAY", "")
389+ socketPath := filepath.Join(t.TempDir(), "wayland.sock")
390+ testCreateUnixSocket(t, socketPath)
391+ t.Setenv("WAYLAND_DISPLAY", socketPath)
392+ t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths
393+ if got := testHasUsableGTKDisplay(); !got {
394+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got)
395+ }
396+ })
397+}
398+
399+func Test_testHasX11Auth_PureProbe(t *testing.T) {
400+ t.Run("DISPLAY unset", func(t *testing.T) {
401+ t.Setenv("DISPLAY", "")
402+ t.Setenv("XAUTHORITY", "")
403+ if got := testHasX11Auth(); !got {
404+ t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got)
405+ }
406+ })
407+
408+ t.Run("DISPLAY TCP remote", func(t *testing.T) {
409+ t.Setenv("DISPLAY", "remote-host:0")
410+ t.Setenv("XAUTHORITY", "")
411+ if got := testHasX11Auth(); !got {
412+ t.Errorf("testHasX11Auth() = %v, want true for TCP display", got)
413+ }
414+ })
415+
416+ t.Run("DISPLAY local no auth file", func(t *testing.T) {
417+ t.Setenv("DISPLAY", ":0")
418+ t.Setenv("XAUTHORITY", "")
419+ // Use a temp home dir with no .Xauthority
420+ t.Setenv("HOME", t.TempDir())
421+ if got := testHasX11Auth(); got {
422+ t.Errorf("testHasX11Auth() = %v, want false without auth file", got)
423+ }
424+ })
425+
426+ t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) {
427+ t.Setenv("DISPLAY", ":0")
428+ t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority")
429+ if got := testHasX11Auth(); got {
430+ t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got)
431+ }
432+ })
433+
434+ t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) {
435+ t.Setenv("DISPLAY", ":1")
436+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
437+ createXauthFile(t, authFile, "1", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
438+ t.Setenv("XAUTHORITY", authFile)
439+ if got := testHasX11Auth(); !got {
440+ t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got)
441+ }
442+ })
443+
444+ t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) {
445+ t.Setenv("DISPLAY", ":2")
446+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
447+ createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
448+ t.Setenv("XAUTHORITY", authFile)
449+ if got := testHasX11Auth(); got {
450+ t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got)
451+ }
452+ })
453+
454+ t.Run("DISPLAY local with screen number", func(t *testing.T) {
455+ t.Setenv("DISPLAY", ":0.0")
456+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
457+ createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
458+ t.Setenv("XAUTHORITY", authFile)
459+ if got := testHasX11Auth(); !got {
460+ t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got)
461+ }
462+ })
463+
464+ t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) {
465+ t.Setenv("DISPLAY", ":")
466+ t.Setenv("XAUTHORITY", "")
467+ if got := testHasX11Auth(); got {
468+ t.Errorf("testHasX11Auth() = %v, want false for empty display number", got)
469+ }
470+ })
471+}
472+
473+// writeXauthRecord appends one local-family .Xauthority record to buf with the given fields.
474+// If name or data is empty, the length byte is written as zero but the field
475+// data is omitted, producing a truncated record suitable for malformed tests.
476+func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) {
477+ // family (2 bytes, big-endian)
478+ familyBytes := make([]byte, 2)
479+ binary.BigEndian.PutUint16(familyBytes, 256)
480+ buf.Write(familyBytes)
481+
482+ // address
483+ addrLen := make([]byte, 2)
484+ addr := "host"
485+ binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
486+ buf.Write(addrLen)
487+ buf.WriteString(addr)
488+
489+ // number
490+ numLen := make([]byte, 2)
491+ binary.BigEndian.PutUint16(numLen, uint16(len(num)))
492+ buf.Write(numLen)
493+ buf.WriteString(num)
494+
495+ // name
496+ nameLen := make([]byte, 2)
497+ binary.BigEndian.PutUint16(nameLen, uint16(len(name)))
498+ buf.Write(nameLen)
499+ if name != "" {
500+ buf.WriteString(name)
501+ }
502+
503+ // data
504+ dLen := make([]byte, 2)
505+ binary.BigEndian.PutUint16(dLen, uint16(len(data)))
506+ buf.Write(dLen)
507+ if len(data) > 0 {
508+ buf.Write(data)
509+ }
510+}
511+
512+func Test_testHasX11Auth_MalformedRecords(t *testing.T) {
513+ t.Run("matching display with empty name field", func(t *testing.T) {
514+ t.Setenv("DISPLAY", ":1")
515+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
516+ var buf bytes.Buffer
517+ writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04})
518+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
519+ t.Fatal(err)
520+ }
521+ t.Setenv("XAUTHORITY", authFile)
522+ if got := testHasX11Auth(); got {
523+ t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got)
524+ }
525+ })
526+
527+ t.Run("matching display with empty data field", func(t *testing.T) {
528+ t.Setenv("DISPLAY", ":1")
529+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
530+ var buf bytes.Buffer
531+ writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil)
532+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
533+ t.Fatal(err)
534+ }
535+ t.Setenv("XAUTHORITY", authFile)
536+ if got := testHasX11Auth(); got {
537+ t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got)
538+ }
539+ })
540+
541+ t.Run("truncated record after display number", func(t *testing.T) {
542+ t.Setenv("DISPLAY", ":1")
543+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
544+ var buf bytes.Buffer
545+ family := make([]byte, 2)
546+ binary.BigEndian.PutUint16(family, 256)
547+ buf.Write(family)
548+ addr := "host"
549+ addrLen := make([]byte, 2)
550+ binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
551+ buf.Write(addrLen)
552+ buf.WriteString(addr)
553+ num := "1"
554+ numLen := make([]byte, 2)
555+ binary.BigEndian.PutUint16(numLen, uint16(len(num)))
556+ buf.Write(numLen)
557+ buf.WriteString(num)
558+ // Record ends here — no name or data fields follow.
559+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
560+ t.Fatal(err)
561+ }
562+ t.Setenv("XAUTHORITY", authFile)
563+ if got := testHasX11Auth(); got {
564+ t.Errorf("testHasX11Auth() = %v, want false for truncated record", got)
565+ }
566+ })
567+
568+ t.Run("multiple entries with one matching and valid", func(t *testing.T) {
569+ t.Setenv("DISPLAY", ":3")
570+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
571+ var buf bytes.Buffer
572+ writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04})
573+ writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08})
574+ writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
575+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
576+ t.Fatal(err)
577+ }
578+ t.Setenv("XAUTHORITY", authFile)
579+ if got := testHasX11Auth(); !got {
580+ t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got)
581+ }
582+ })
583+
584+ t.Run("invalid matching entry does not hide later valid match", func(t *testing.T) {
585+ t.Setenv("DISPLAY", ":3")
586+ authFile := filepath.Join(t.TempDir(), ".Xauthority")
587+ var buf bytes.Buffer
588+ writeXauthRecord(&buf, "3", "", []byte{0x01, 0x02, 0x03, 0x04})
589+ writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
590+ if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
591+ t.Fatal(err)
592+ }
593+ t.Setenv("XAUTHORITY", authFile)
594+ if got := testHasX11Auth(); !got {
595+ t.Errorf("testHasX11Auth() = %v, want true for later valid matching display", got)
596+ }
597+ })
598+}
599+
600+func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) {
601+ t.Setenv("GDK_BACKEND", "")
602+
603+ t.Run("no WAYLAND_DISPLAY", func(t *testing.T) {
604+ t.Setenv("WAYLAND_DISPLAY", "")
605+ if got := testHasUsableWaylandDisplay(); got {
606+ t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got)
607+ }
608+ })
609+
610+ t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
611+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
612+ tmpDir := t.TempDir()
613+ socketPath := filepath.Join(tmpDir, "wayland-1")
614+ testCreateUnixSocket(t, socketPath)
615+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
616+ if got := testHasUsableWaylandDisplay(); !got {
617+ t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got)
618+ }
619+ })
620+
621+ t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) {
622+ socketPath := filepath.Join(t.TempDir(), "wayland.sock")
623+ testCreateUnixSocket(t, socketPath)
624+ t.Setenv("WAYLAND_DISPLAY", socketPath)
625+ t.Setenv("XDG_RUNTIME_DIR", "/nonexistent")
626+ if got := testHasUsableWaylandDisplay(); !got {
627+ t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got)
628+ }
629+ })
630+}
631+
632+func Test_testHasUsableX11Display_PureProbe(t *testing.T) {
633+ t.Setenv("GDK_BACKEND", "")
634+
635+ t.Run("no DISPLAY", func(t *testing.T) {
636+ t.Setenv("DISPLAY", "")
637+ if got := testHasUsableX11Display(); got {
638+ t.Errorf("testHasUsableX11Display() = %v, want false", got)
639+ }
640+ })
641+
642+ t.Run("DISPLAY numeric without socket", func(t *testing.T) {
643+ displayNum := testUnusedX11DisplayNumber(t)
644+ t.Setenv("DISPLAY", ":"+displayNum)
645+ if got := testHasUsableX11Display(); got {
646+ t.Errorf("testHasUsableX11Display() = %v, want false", got)
647+ }
648+ })
649+
650+ t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
651+ t.Setenv("DISPLAY", "remote-host:0")
652+ if got := testHasUsableX11Display(); !got {
653+ t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got)
654+ }
655+ })
656+}
657+
658+func Test_testHasUsableGTKDisplay_GDKBackendSelection(t *testing.T) {
659+ t.Run("GDK_BACKEND=x11 ignores Wayland-only socket", func(t *testing.T) {
660+ t.Setenv("GDK_BACKEND", "x11")
661+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
662+ tmpDir := t.TempDir()
663+ testCreateUnixSocket(t, filepath.Join(tmpDir, "wayland-1"))
664+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
665+ t.Setenv("DISPLAY", ":"+testUnusedX11DisplayNumber(t))
666+ if got := testHasUsableGTKDisplay(); got {
667+ t.Errorf("testHasUsableGTKDisplay() = %v, want false when x11 is forced without an X11 socket", got)
668+ }
669+ })
670+
671+ t.Run("GDK_BACKEND=wayland ignores X11-only socket", func(t *testing.T) {
672+ displayNum := testUnusedX11DisplayNumber(t)
673+ t.Setenv("GDK_BACKEND", "wayland")
674+ t.Setenv("DISPLAY", ":"+displayNum)
675+ t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
676+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
677+ testCreateX11Socket(t, displayNum)
678+ if got := testHasUsableGTKDisplay(); got {
679+ t.Errorf("testHasUsableGTKDisplay() = %v, want false when wayland is forced without a Wayland socket", got)
680+ }
681+ })
682+
683+ t.Run("GDK_BACKEND=wayland,x11 allows X11 fallback", func(t *testing.T) {
684+ displayNum := testUnusedX11DisplayNumber(t)
685+ t.Setenv("GDK_BACKEND", "wayland,x11")
686+ t.Setenv("DISPLAY", ":"+displayNum)
687+ t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
688+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
689+ testCreateX11Socket(t, displayNum)
690+ if got := testHasUsableGTKDisplay(); !got {
691+ t.Errorf("testHasUsableGTKDisplay() = %v, want true with X11 fallback socket", got)
692+ }
693+ if got := testNeedsX11Auth(); !got {
694+ t.Errorf("testNeedsX11Auth() = %v, want true when X11 is the only usable backend", got)
695+ }
696+ })
697+}
698+
699+func Test_testNeedsX11Auth_PureProbe(t *testing.T) {
700+ t.Run("Wayland available forces no X11 auth", func(t *testing.T) {
701+ t.Setenv("GDK_BACKEND", "")
702+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
703+ tmpDir := t.TempDir()
704+ socketPath := filepath.Join(tmpDir, "wayland-1")
705+ testCreateUnixSocket(t, socketPath)
706+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
707+ t.Setenv("DISPLAY", ":0")
708+ if got := testNeedsX11Auth(); got {
709+ t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got)
710+ }
711+ })
712+
713+ t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) {
714+ t.Setenv("GDK_BACKEND", "x11")
715+ t.Setenv("WAYLAND_DISPLAY", "wayland-1")
716+ tmpDir := t.TempDir()
717+ socketPath := filepath.Join(tmpDir, "wayland-1")
718+ testCreateUnixSocket(t, socketPath)
719+ t.Setenv("XDG_RUNTIME_DIR", tmpDir)
720+ if got := testNeedsX11Auth(); !got {
721+ t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got)
722+ }
723+ })
724+
725+ t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) {
726+ t.Setenv("GDK_BACKEND", "")
727+ t.Setenv("WAYLAND_DISPLAY", "")
728+ t.Setenv("DISPLAY", ":0")
729+ if got := testNeedsX11Auth(); !got {
730+ t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got)
731+ }
732+ })
733+}
734+
735+// createXauthFile writes a minimal .Xauthority file with one entry.
736+// family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie.
737+func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) {
738+ t.Helper()
739+
740+ hostname, err := os.Hostname()
741+ if err != nil {
742+ hostname = "localhost"
743+ }
744+
745+ var buf bytes.Buffer
746+ // family (2 bytes, big-endian) - 256 = FamilyLocal
747+ family := make([]byte, 2)
748+ binary.BigEndian.PutUint16(family, 256)
749+ buf.Write(family)
750+
751+ // address length + address (hostname)
752+ addrLen := make([]byte, 2)
753+ binary.BigEndian.PutUint16(addrLen, uint16(len(hostname)))
754+ buf.Write(addrLen)
755+ buf.WriteString(hostname)
756+
757+ // number length + number (display number)
758+ numLen := make([]byte, 2)
759+ binary.BigEndian.PutUint16(numLen, uint16(len(displayNum)))
760+ buf.Write(numLen)
761+ buf.WriteString(displayNum)
762+
763+ // name length + name (auth protocol)
764+ nameLen := make([]byte, 2)
765+ binary.BigEndian.PutUint16(nameLen, uint16(len(proto)))
766+ buf.Write(nameLen)
767+ buf.WriteString(proto)
768+
769+ // data length + data (auth cookie)
770+ dataLen := make([]byte, 2)
771+ binary.BigEndian.PutUint16(dataLen, uint16(len(cookie)))
772+ buf.Write(dataLen)
773+ buf.Write(cookie)
774+
775+ if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
776+ t.Fatal(err)
777+ }
778+}
779+
136 // setDependencyField uses reflection to reach unexported dependency fields for tests.780 // setDependencyField uses reflection to reach unexported dependency fields for tests.
137 // Keep this test-only so production visibility stays narrow.781 // Keep this test-only so production visibility stays narrow.
138 func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {782 func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {

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 8

Tags 42

↑↓ moveenter openesc close