detached 722264a

test(xtest): compare live Xvfb captures independently of the server build

The live comparison failed on other Xvfb builds because goldens embed
server-chosen values. Each golden now lists its server-chosen fields by
offset (root window, visual, colormap, interned atom, resource IDs,
extension opcodes and event/error bases, timestamps). -update records
their capture-time values in testdata/golden/xvfb-roles.json; compare mode
checks the golden holds the recorded values and the live packet the live
server's, masks both and compares the rest exactly.

ListExtensions and Render QueryPictFormats depend on the server build, so
live compare checks their framing, list counts, bound extensions and the
root visual's picture format instead of bytes. XTURBO_XVFB_ARGS adds Xvfb
options to simulate another server layout. Committed goldens and unit
tests are unchanged.
commit
722264ac773fc5df4f73671ee4fcce79537e93a6
author
brice · · 2026-10-22 08:22 UTC
parent
d5bc75b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
4/4
3 files · +296 -36
M internal/xtest/README.md +21 -4
@@ -1,10 +1,27 @@
11 # Wire goldens
22
33 `make xserver` captures from a fresh Xvfb and compares every packet with the
4-committed golden, failing on any difference. Server timestamps
5-(PropertyNotify time, XFIXES SelectionNotify timestamps) vary per run and are
6-excluded from the comparison. The test is skipped when Xvfb is not installed.
7-Rewrite the goldens from the repository root with:
4+committed golden. Comparison is independent of the server build:
5+
6+- Server-chosen values (root window, visual, colormap, interned atom,
7+ resource IDs from the client's resource base, extension opcodes and
8+ event/error bases) are listed per golden by field offset in
9+ `serverFields`. `-update` records their capture-time values in
10+ `testdata/golden/xvfb-roles.json`. Compare mode checks that the golden holds
11+ the recorded value and the live packet holds the live server's value, masks
12+ both, then compares the remaining bytes exactly. Server timestamps are
13+ masked without a value check.
14+- `ListExtensions` and Render `QueryPictFormats` replies depend on how the
15+ server was built (extension list, visuals, formats), so live compare
16+ checks invariants: framing, counts matching list lengths, the bound
17+ extensions present, the root visual mapped to the format CreatePicture uses.
18+
19+Unit tests still decode the committed goldens byte-exactly. The test is
20+skipped when Xvfb is not installed. `XTURBO_XVFB_ARGS` adds Xvfb options to
21+simulate another server layout, e.g. `XTURBO_XVFB_ARGS="-extension GLX"`.
22+Screen size and depth (800x600x24) and the bound extensions are capture
23+inputs and must stay as captured. Rewrite the goldens from the repository
24+root with:
825
926 ```
1027 PATH="$PATH:/usr/sbin" go test -tags xserver ./internal/xtest -update
@@ -1,10 +1,27 @@
1 # Wire goldens1 # Wire goldens
2 2
3 `make xserver` captures from a fresh Xvfb and compares every packet with the3 `make xserver` captures from a fresh Xvfb and compares every packet with the
4-committed golden, failing on any difference. Server timestamps4+committed golden. Comparison is independent of the server build:
5-(PropertyNotify time, XFIXES SelectionNotify timestamps) vary per run and are5+
6-excluded from the comparison. The test is skipped when Xvfb is not installed.6+- Server-chosen values (root window, visual, colormap, interned atom,
7-Rewrite the goldens from the repository root with:7+ resource IDs from the client's resource base, extension opcodes and
8+ event/error bases) are listed per golden by field offset in
9+ `serverFields`. `-update` records their capture-time values in
10+ `testdata/golden/xvfb-roles.json`. Compare mode checks that the golden holds
11+ the recorded value and the live packet holds the live server's value, masks
12+ both, then compares the remaining bytes exactly. Server timestamps are
13+ masked without a value check.
14+- `ListExtensions` and Render `QueryPictFormats` replies depend on how the
15+ server was built (extension list, visuals, formats), so live compare
16+ checks invariants: framing, counts matching list lengths, the bound
17+ extensions present, the root visual mapped to the format CreatePicture uses.
18+
19+Unit tests still decode the committed goldens byte-exactly. The test is
20+skipped when Xvfb is not installed. `XTURBO_XVFB_ARGS` adds Xvfb options to
21+simulate another server layout, e.g. `XTURBO_XVFB_ARGS="-extension GLX"`.
22+Screen size and depth (800x600x24) and the bound extensions are capture
23+inputs and must stay as captured. Rewrite the goldens from the repository
24+root with:
8 25
9 ```26 ```
10 PATH="$PATH:/usr/sbin" go test -tags xserver ./internal/xtest -update27 PATH="$PATH:/usr/sbin" go test -tags xserver ./internal/xtest -update
M internal/xtest/capture_test.go +256 -32
@@ -6,13 +6,16 @@ import (
66 "bytes"
77 "encoding/binary"
88 "encoding/hex"
9+ "encoding/json"
910 "flag"
1011 "fmt"
1112 "io"
13+ "maps"
1214 "net"
1315 "os"
1416 "os/exec"
1517 "path/filepath"
18+ "slices"
1619 "strconv"
1720 "strings"
1821 "testing"
@@ -23,11 +26,67 @@ import (
2326
2427 var update = flag.Bool("update", false, "rewrite wire goldens from Xvfb instead of comparing against them")
2528
26-// volatile lists server-time fields that differ on every run; comparison
27-// ignores these byte ranges [from, to). Everything else must match exactly.
28-var volatile = map[string][][2]int{
29- "xproto/PropertyNotify.event.hex": {{12, 16}}, // time
30- "xfixes/SelectionNotify.event.hex": {{16, 24}}, // timestamp, selection_timestamp
29+// field is a server-dependent value inside a golden: the size-byte
30+// little-endian integer at Off must equal the server's value for Role.
31+type field struct {
32+ Off, Size int
33+ Role string
34+}
35+
36+// serverFields lists, per golden, every value the X server chooses: resource
37+// IDs derived from a server-assigned base, the root window and its visual and
38+// colormap, interned atoms, extension opcodes and bases, and timestamps.
39+// Compare mode checks that the golden holds the value recorded at capture
40+// (xvfb-roles.json) and the live packet holds the live server's value, then
41+// masks both and compares the remaining bytes exactly. Role "time" is a
42+// per-run server timestamp and is masked without a value check.
43+var serverFields = map[string][]field{
44+ "xproto/MapWindow.req": {{4, 4, "root"}},
45+ "xproto/GetGeometry.req": {{4, 4, "root"}},
46+ "xproto/ChangeProperty.req": {{4, 4, "root"}, {8, 4, "atom"}},
47+ "xproto/CreateWindow.req": {{4, 4, "child"}, {8, 4, "root"}},
48+ "xproto/ChangeWindowAttributes.req": {{4, 4, "child"}},
49+ "xproto/ConfigureWindow.req": {{4, 4, "child"}},
50+ "xproto/ConfigureWindowSparse.req": {{4, 4, "child"}},
51+ "xproto/ConfigureWindowAll.req": {{4, 4, "child"}, {32, 4, "sibling"}},
52+ "render/CreatePicture.req": {{0, 1, "renderMajor"}, {4, 4, "picture"}, {8, 4, "child"}, {12, 4, "rootFormat"}},
53+ "res/QueryClientIds.req": {{0, 1, "resMajor"}},
54+ "xfixes/QueryVersion.req": {{0, 1, "xfixesMajor"}},
55+ "xproto/QueryExtension.reply": {{9, 1, "xfixesMajor"}, {10, 1, "xfixesEvent"}, {11, 1, "xfixesError"}},
56+ "xproto/GetGeometry.reply": {{8, 4, "root"}},
57+ "xproto/InternAtom.reply": {{8, 4, "atom"}},
58+ "xproto/GetWindowAttributes.reply": {{8, 4, "rootVisual"}, {28, 4, "rootColormap"}},
59+ "xproto/QueryTree.reply": {{8, 4, "root"}, {32, 4, "sibling"}, {36, 4, "child"}},
60+ "res/QueryClientIds.reply": {{44, 4, "base"}},
61+ "xproto/CreateNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
62+ "xproto/MapRequest.event": {{4, 4, "root"}, {8, 4, "client2"}},
63+ "xproto/ConfigureRequest.event": {{4, 4, "root"}, {8, 4, "client2"}},
64+ "xproto/MapNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
65+ "xproto/UnmapNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
66+ "xproto/DestroyNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
67+ "xproto/PropertyNotify.event": {{4, 4, "root"}, {8, 4, "atom"}, {12, 4, "time"}},
68+ "xproto/ClientMessage.event": {{4, 4, "client2"}, {8, 4, "atom"}},
69+ "xproto/SelectionNotify.event": {{8, 4, "child"}},
70+ "xfixes/SelectionNotify.event": {{0, 1, "xfixesEvent"}, {4, 4, "root"}, {8, 4, "client2"}, {16, 4, "time"}, {20, 4, "time"}},
71+ "xproto/BadWindow.error": {{4, 4, "badWindow"}},
72+ "xfixes/BadRegion.error": {{1, 1, "xfixesError"}, {4, 4, "badRegion"}, {10, 1, "xfixesMajor"}},
73+}
74+
75+// structural replies depend on the server build itself (extension list,
76+// visuals and picture formats), so live compare checks invariants instead of
77+// bytes; unit tests still decode the committed golden byte-exactly.
78+var structural = map[string]func(p []byte, roles map[string]uint32) error{
79+ "xproto/ListExtensions.reply": checkListExtensions,
80+ "render/QueryPictFormats.reply": checkQueryPictFormats,
81+}
82+
83+const rolesPath = "../../testdata/golden/xvfb-roles.json"
84+
85+// xvfbArgs lets a run simulate a different server layout, e.g.
86+// XTURBO_XVFB_ARGS="-extension GLX" shifts resource IDs, atoms and visuals.
87+func xvfbArgs() []string {
88+ args := []string{"-displayfd", "3", "-screen", "0", "800x600x24", "-nolisten", "tcp", "-ac"}
89+ return append(args, strings.Fields(os.Getenv("XTURBO_XVFB_ARGS"))...)
3190 }
3291
3392 // TestCaptureRequests drives a fresh Xvfb through a raw socketpair tee. By
@@ -43,7 +102,7 @@ func TestCaptureRequests(t *testing.T) {
43102 t.Fatal(err)
44103 }
45104 defer r.Close()
46- cmd := exec.Command("Xvfb", "-displayfd", "3", "-screen", "0", "800x600x24", "-nolisten", "tcp", "-ac")
105+ cmd := exec.Command("Xvfb", xvfbArgs()...)
47106 cmd.ExtraFiles = []*os.File{w}
48107 var log bytes.Buffer
49108 cmd.Stderr = &log
@@ -512,12 +571,6 @@ func TestCaptureRequests(t *testing.T) {
512571 if len(events) != 10 || len(xerrors) != 3 {
513572 t.Fatalf("captured %d events, %d errors", len(events), len(xerrors))
514573 }
515- for name, p := range events {
516- golden(t, name+".event.hex", "# source: Xvfb event via raw socketpair tee; triggered by an independent second client or reference request\n", p)
517- }
518- for name, p := range xerrors {
519- golden(t, name+".error.hex", "# source: Xvfb error via raw socketpair tee to an independent reference request\n", p)
520- }
521574 // Closing the client drains the request direction; wait before inspecting tee.
522575 client.Close()
523576 select {
@@ -577,40 +630,211 @@ func TestCaptureRequests(t *testing.T) {
577630 if len(captured) != 12 || len(replies) != 11 {
578631 t.Fatal("missing captured requests or replies")
579632 }
633+ out := map[string][]byte{}
580634 for name, p := range replies {
581- golden(t, name+".reply.hex", "# source: Xvfb reply via raw socketpair tee to an independent reference request\n", p)
635+ out[name+".reply"] = p
582636 }
583637 for name, p := range captured {
584- golden(t, name+".req.hex", "# source: Xvfb via raw socketpair tee; independent reference encoder; server accepted\n", p)
638+ out[name+".req"] = p
585639 }
586-}
587-
588-// golden writes p (with -update) or compares it with the committed golden.
589-func golden(t *testing.T, name, source string, p []byte) {
590- t.Helper()
591- path := filepath.Join("../../testdata/golden", name)
592- if !*update {
593- want, err := LoadHex(path)
594- if err != nil {
595- t.Errorf("%s: %v (run with -update to create it)", name, err)
596- return
640+ for name, p := range events {
641+ out[name+".event"] = p
642+ }
643+ for name, p := range xerrors {
644+ out[name+".error"] = p
645+ }
646+ // Server-chosen values, keyed by the roles used in serverFields.
647+ roles := map[string]uint32{
648+ "root": root, "atom": atom, "base": base, "child": child, "sibling": sibling, "picture": picture,
649+ "client2": client2, "badWindow": base | 0x7777, "badRegion": base | 0x8888,
650+ "rootVisual": rootVisual, "rootColormap": binary.LittleEndian.Uint32(setup[screenOff+4:]), "rootFormat": format,
651+ "xfixesMajor": uint32(major), "xfixesEvent": uint32(xfixesEvent), "xfixesError": uint32(xfixesError),
652+ "resMajor": uint32(resMajor), "renderMajor": uint32(renderMajor),
653+ }
654+ if *update {
655+ for name, p := range out {
656+ writeGolden(t, name, p)
597657 }
598- got := bytes.Clone(p)
599- if len(got) == len(want) {
600- for _, r := range volatile[name] {
601- copy(got[r[0]:r[1]], want[r[0]:r[1]])
602- }
658+ rolesJSON, err := json.MarshalIndent(roles, "", " ")
659+ if err != nil {
660+ t.Fatal(err)
603661 }
604- if !bytes.Equal(got, want) {
605- t.Errorf("%s differs from live Xvfb:\n got %x\nwant %x", name, p, want)
662+ if err := os.WriteFile(rolesPath, append(rolesJSON, '\n'), 0644); err != nil {
663+ t.Fatal(err)
606664 }
607665 return
608666 }
667+ rolesJSON, err := os.ReadFile(rolesPath)
668+ if err != nil {
669+ t.Fatal(err)
670+ }
671+ var capturedRoles map[string]uint32
672+ if err := json.Unmarshal(rolesJSON, &capturedRoles); err != nil {
673+ t.Fatal(err)
674+ }
675+ for _, role := range slices.Sorted(maps.Keys(roles)) {
676+ if roles[role] != capturedRoles[role] {
677+ t.Logf("server-chosen %s: live %#x, golden %#x", role, roles[role], capturedRoles[role])
678+ }
679+ }
680+ for name, p := range out {
681+ compareGolden(t, name, p, roles, capturedRoles)
682+ }
683+}
684+
685+var sources = map[string]string{
686+ "req": "# source: Xvfb via raw socketpair tee; independent reference encoder; server accepted\n",
687+ "reply": "# source: Xvfb reply via raw socketpair tee to an independent reference request\n",
688+ "event": "# source: Xvfb event via raw socketpair tee; triggered by an independent second client or reference request\n",
689+ "error": "# source: Xvfb error via raw socketpair tee to an independent reference request\n",
690+}
691+
692+func writeGolden(t *testing.T, name string, p []byte) {
693+ t.Helper()
694+ path := filepath.Join("../../testdata/golden", name+".hex")
609695 if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
610696 t.Fatal(err)
611697 }
698+ source := sources[name[strings.LastIndexByte(name, '.')+1:]]
612699 text := source + "# xtrace fallback: local xtrace is glibc's execution tracer, not X11 xtrace\n" + hex.EncodeToString(p) + "\n"
613700 if err := os.WriteFile(path, []byte(text), 0644); err != nil {
614701 t.Fatal(err)
615702 }
616703 }
704+
705+// compareGolden checks a live packet against the committed golden without
706+// depending on the server build: server-chosen fields must hold each side's
707+// own value (live roles, recorded roles) and are masked before an exact
708+// comparison; structural replies are checked for invariants only.
709+func compareGolden(t *testing.T, name string, live []byte, roles, recorded map[string]uint32) {
710+ t.Helper()
711+ want, err := LoadHex(filepath.Join("../../testdata/golden", name+".hex"))
712+ if err != nil {
713+ t.Errorf("%s: %v (run with -update to create it)", name, err)
714+ return
715+ }
716+ if check := structural[name]; check != nil {
717+ if err := check(live, roles); err != nil {
718+ t.Errorf("%s (live): %v", name, err)
719+ }
720+ if err := check(want, recorded); err != nil {
721+ t.Errorf("%s (golden): %v", name, err)
722+ }
723+ return
724+ }
725+ got, exp, err := normalize(name, live, roles), normalize(name, want, recorded), error(nil)
726+ for _, e := range []struct {
727+ side string
728+ err error
729+ }{{"live", got.err}, {"golden", exp.err}} {
730+ if e.err != nil {
731+ t.Errorf("%s (%s): %v", name, e.side, e.err)
732+ err = e.err
733+ }
734+ }
735+ if err == nil && !bytes.Equal(got.b, exp.b) {
736+ t.Errorf("%s differs from live Xvfb after masking server-chosen fields:\n got %x\nwant %x", name, live, want)
737+ }
738+}
739+
740+type normalized struct {
741+ b []byte
742+ err error
743+}
744+
745+// normalize verifies each server-chosen field of p holds roles[Role] and
746+// replaces it with zero bytes.
747+func normalize(name string, p []byte, roles map[string]uint32) normalized {
748+ b := bytes.Clone(p)
749+ for _, f := range serverFields[name] {
750+ if f.Off+f.Size > len(b) {
751+ return normalized{err: fmt.Errorf("field %s at %d beyond %d bytes", f.Role, f.Off, len(b))}
752+ }
753+ if f.Role != "time" {
754+ v, ok := roles[f.Role]
755+ if !ok {
756+ return normalized{err: fmt.Errorf("no recorded value for role %s", f.Role)}
757+ }
758+ var got uint32
759+ if f.Size == 1 {
760+ got = uint32(b[f.Off])
761+ } else {
762+ got = binary.LittleEndian.Uint32(b[f.Off:])
763+ }
764+ if got != v {
765+ return normalized{err: fmt.Errorf("field %s at %d = %#x, want %#x", f.Role, f.Off, got, v)}
766+ }
767+ }
768+ clear(b[f.Off : f.Off+f.Size])
769+ }
770+ return normalized{b: b}
771+}
772+
773+// checkListExtensions: a framed reply whose STR list holds exactly names_len
774+// entries filling the reply, including the extensions xturbo binds.
775+func checkListExtensions(p []byte, _ map[string]uint32) error {
776+ if len(p) < 32 || p[0] != 1 || len(p) != 32+4*int(binary.LittleEndian.Uint32(p[4:])) {
777+ return fmt.Errorf("bad framing")
778+ }
779+ names := map[string]bool{}
780+ off := 32
781+ for range int(p[1]) {
782+ if off >= len(p) || off+1+int(p[off]) > len(p) {
783+ return fmt.Errorf("STR list overruns the reply")
784+ }
785+ names[string(p[off+1:off+1+int(p[off])])] = true
786+ off += 1 + int(p[off])
787+ }
788+ if (off+3)&^3 != len(p) {
789+ return fmt.Errorf("STR list ends at %d, reply has %d bytes", off, len(p))
790+ }
791+ for _, want := range []string{"BIG-REQUESTS", "XC-MISC", "XFIXES", "RENDER", "SHAPE", "Composite", "X-Resource", "Generic Event Extension"} {
792+ if !names[want] {
793+ return fmt.Errorf("missing extension %q", want)
794+ }
795+ }
796+ return nil
797+}
798+
799+// checkQueryPictFormats: declared counts match the list sizes exactly and the
800+// root visual maps to the picture format CreatePicture used.
801+func checkQueryPictFormats(p []byte, roles map[string]uint32) error {
802+ u32 := binary.LittleEndian.Uint32
803+ if len(p) < 32 || p[0] != 1 || len(p) != 32+4*int(u32(p[4:])) {
804+ return fmt.Errorf("bad framing")
805+ }
806+ formats, screens, subpixels := int(u32(p[8:])), int(u32(p[12:])), int(u32(p[24:]))
807+ off := 32 + 28*formats
808+ found := false
809+ for range screens {
810+ if off+8 > len(p) {
811+ return fmt.Errorf("screens overrun")
812+ }
813+ depths := int(u32(p[off:]))
814+ off += 8
815+ for range depths {
816+ if off+8 > len(p) {
817+ return fmt.Errorf("depths overrun")
818+ }
819+ visuals := int(binary.LittleEndian.Uint16(p[off+2:]))
820+ off += 8
821+ for range visuals {
822+ if off+8 > len(p) {
823+ return fmt.Errorf("visuals overrun")
824+ }
825+ if u32(p[off:]) == roles["rootVisual"] && u32(p[off+4:]) == roles["rootFormat"] {
826+ found = true
827+ }
828+ off += 8
829+ }
830+ }
831+ }
832+ off += 4 * subpixels
833+ if off != len(p) {
834+ return fmt.Errorf("lists end at %d, reply has %d bytes", off, len(p))
835+ }
836+ if !found {
837+ return fmt.Errorf("root visual %#x does not map to format %#x", roles["rootVisual"], roles["rootFormat"])
838+ }
839+ return nil
840+}
@@ -6,13 +6,16 @@ import (
6 "bytes"6 "bytes"
7 "encoding/binary"7 "encoding/binary"
8 "encoding/hex"8 "encoding/hex"
9+ "encoding/json"
9 "flag"10 "flag"
10 "fmt"11 "fmt"
11 "io"12 "io"
13+ "maps"
12 "net"14 "net"
13 "os"15 "os"
14 "os/exec"16 "os/exec"
15 "path/filepath"17 "path/filepath"
18+ "slices"
16 "strconv"19 "strconv"
17 "strings"20 "strings"
18 "testing"21 "testing"
@@ -23,11 +26,67 @@ import (
23 26
24 var update = flag.Bool("update", false, "rewrite wire goldens from Xvfb instead of comparing against them")27 var update = flag.Bool("update", false, "rewrite wire goldens from Xvfb instead of comparing against them")
25 28
26-// volatile lists server-time fields that differ on every run; comparison29+// field is a server-dependent value inside a golden: the size-byte
27-// ignores these byte ranges [from, to). Everything else must match exactly.30+// little-endian integer at Off must equal the server's value for Role.
28-var volatile = map[string][][2]int{31+type field struct {
29- "xproto/PropertyNotify.event.hex": {{12, 16}}, // time32+ Off, Size int
30- "xfixes/SelectionNotify.event.hex": {{16, 24}}, // timestamp, selection_timestamp33+ Role string
34+}
35+
36+// serverFields lists, per golden, every value the X server chooses: resource
37+// IDs derived from a server-assigned base, the root window and its visual and
38+// colormap, interned atoms, extension opcodes and bases, and timestamps.
39+// Compare mode checks that the golden holds the value recorded at capture
40+// (xvfb-roles.json) and the live packet holds the live server's value, then
41+// masks both and compares the remaining bytes exactly. Role "time" is a
42+// per-run server timestamp and is masked without a value check.
43+var serverFields = map[string][]field{
44+ "xproto/MapWindow.req": {{4, 4, "root"}},
45+ "xproto/GetGeometry.req": {{4, 4, "root"}},
46+ "xproto/ChangeProperty.req": {{4, 4, "root"}, {8, 4, "atom"}},
47+ "xproto/CreateWindow.req": {{4, 4, "child"}, {8, 4, "root"}},
48+ "xproto/ChangeWindowAttributes.req": {{4, 4, "child"}},
49+ "xproto/ConfigureWindow.req": {{4, 4, "child"}},
50+ "xproto/ConfigureWindowSparse.req": {{4, 4, "child"}},
51+ "xproto/ConfigureWindowAll.req": {{4, 4, "child"}, {32, 4, "sibling"}},
52+ "render/CreatePicture.req": {{0, 1, "renderMajor"}, {4, 4, "picture"}, {8, 4, "child"}, {12, 4, "rootFormat"}},
53+ "res/QueryClientIds.req": {{0, 1, "resMajor"}},
54+ "xfixes/QueryVersion.req": {{0, 1, "xfixesMajor"}},
55+ "xproto/QueryExtension.reply": {{9, 1, "xfixesMajor"}, {10, 1, "xfixesEvent"}, {11, 1, "xfixesError"}},
56+ "xproto/GetGeometry.reply": {{8, 4, "root"}},
57+ "xproto/InternAtom.reply": {{8, 4, "atom"}},
58+ "xproto/GetWindowAttributes.reply": {{8, 4, "rootVisual"}, {28, 4, "rootColormap"}},
59+ "xproto/QueryTree.reply": {{8, 4, "root"}, {32, 4, "sibling"}, {36, 4, "child"}},
60+ "res/QueryClientIds.reply": {{44, 4, "base"}},
61+ "xproto/CreateNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
62+ "xproto/MapRequest.event": {{4, 4, "root"}, {8, 4, "client2"}},
63+ "xproto/ConfigureRequest.event": {{4, 4, "root"}, {8, 4, "client2"}},
64+ "xproto/MapNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
65+ "xproto/UnmapNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
66+ "xproto/DestroyNotify.event": {{4, 4, "root"}, {8, 4, "client2"}},
67+ "xproto/PropertyNotify.event": {{4, 4, "root"}, {8, 4, "atom"}, {12, 4, "time"}},
68+ "xproto/ClientMessage.event": {{4, 4, "client2"}, {8, 4, "atom"}},
69+ "xproto/SelectionNotify.event": {{8, 4, "child"}},
70+ "xfixes/SelectionNotify.event": {{0, 1, "xfixesEvent"}, {4, 4, "root"}, {8, 4, "client2"}, {16, 4, "time"}, {20, 4, "time"}},
71+ "xproto/BadWindow.error": {{4, 4, "badWindow"}},
72+ "xfixes/BadRegion.error": {{1, 1, "xfixesError"}, {4, 4, "badRegion"}, {10, 1, "xfixesMajor"}},
73+}
74+
75+// structural replies depend on the server build itself (extension list,
76+// visuals and picture formats), so live compare checks invariants instead of
77+// bytes; unit tests still decode the committed golden byte-exactly.
78+var structural = map[string]func(p []byte, roles map[string]uint32) error{
79+ "xproto/ListExtensions.reply": checkListExtensions,
80+ "render/QueryPictFormats.reply": checkQueryPictFormats,
81+}
82+
83+const rolesPath = "../../testdata/golden/xvfb-roles.json"
84+
85+// xvfbArgs lets a run simulate a different server layout, e.g.
86+// XTURBO_XVFB_ARGS="-extension GLX" shifts resource IDs, atoms and visuals.
87+func xvfbArgs() []string {
88+ args := []string{"-displayfd", "3", "-screen", "0", "800x600x24", "-nolisten", "tcp", "-ac"}
89+ return append(args, strings.Fields(os.Getenv("XTURBO_XVFB_ARGS"))...)
31 }90 }
32 91
33 // TestCaptureRequests drives a fresh Xvfb through a raw socketpair tee. By92 // TestCaptureRequests drives a fresh Xvfb through a raw socketpair tee. By
@@ -43,7 +102,7 @@ func TestCaptureRequests(t *testing.T) {
43 t.Fatal(err)102 t.Fatal(err)
44 }103 }
45 defer r.Close()104 defer r.Close()
46- cmd := exec.Command("Xvfb", "-displayfd", "3", "-screen", "0", "800x600x24", "-nolisten", "tcp", "-ac")105+ cmd := exec.Command("Xvfb", xvfbArgs()...)
47 cmd.ExtraFiles = []*os.File{w}106 cmd.ExtraFiles = []*os.File{w}
48 var log bytes.Buffer107 var log bytes.Buffer
49 cmd.Stderr = &log108 cmd.Stderr = &log
@@ -512,12 +571,6 @@ func TestCaptureRequests(t *testing.T) {
512 if len(events) != 10 || len(xerrors) != 3 {571 if len(events) != 10 || len(xerrors) != 3 {
513 t.Fatalf("captured %d events, %d errors", len(events), len(xerrors))572 t.Fatalf("captured %d events, %d errors", len(events), len(xerrors))
514 }573 }
515- for name, p := range events {
516- golden(t, name+".event.hex", "# source: Xvfb event via raw socketpair tee; triggered by an independent second client or reference request\n", p)
517- }
518- for name, p := range xerrors {
519- golden(t, name+".error.hex", "# source: Xvfb error via raw socketpair tee to an independent reference request\n", p)
520- }
521 // Closing the client drains the request direction; wait before inspecting tee.574 // Closing the client drains the request direction; wait before inspecting tee.
522 client.Close()575 client.Close()
523 select {576 select {
@@ -577,40 +630,211 @@ func TestCaptureRequests(t *testing.T) {
577 if len(captured) != 12 || len(replies) != 11 {630 if len(captured) != 12 || len(replies) != 11 {
578 t.Fatal("missing captured requests or replies")631 t.Fatal("missing captured requests or replies")
579 }632 }
633+ out := map[string][]byte{}
580 for name, p := range replies {634 for name, p := range replies {
581- golden(t, name+".reply.hex", "# source: Xvfb reply via raw socketpair tee to an independent reference request\n", p)635+ out[name+".reply"] = p
582 }636 }
583 for name, p := range captured {637 for name, p := range captured {
584- golden(t, name+".req.hex", "# source: Xvfb via raw socketpair tee; independent reference encoder; server accepted\n", p)638+ out[name+".req"] = p
585 }639 }
586-}640+ for name, p := range events {
587-641+ out[name+".event"] = p
588-// golden writes p (with -update) or compares it with the committed golden.642+ }
589-func golden(t *testing.T, name, source string, p []byte) {643+ for name, p := range xerrors {
590- t.Helper()644+ out[name+".error"] = p
591- path := filepath.Join("../../testdata/golden", name)645+ }
592- if !*update {646+ // Server-chosen values, keyed by the roles used in serverFields.
593- want, err := LoadHex(path)647+ roles := map[string]uint32{
594- if err != nil {648+ "root": root, "atom": atom, "base": base, "child": child, "sibling": sibling, "picture": picture,
595- t.Errorf("%s: %v (run with -update to create it)", name, err)649+ "client2": client2, "badWindow": base | 0x7777, "badRegion": base | 0x8888,
596- return650+ "rootVisual": rootVisual, "rootColormap": binary.LittleEndian.Uint32(setup[screenOff+4:]), "rootFormat": format,
651+ "xfixesMajor": uint32(major), "xfixesEvent": uint32(xfixesEvent), "xfixesError": uint32(xfixesError),
652+ "resMajor": uint32(resMajor), "renderMajor": uint32(renderMajor),
653+ }
654+ if *update {
655+ for name, p := range out {
656+ writeGolden(t, name, p)
597 }657 }
598- got := bytes.Clone(p)658+ rolesJSON, err := json.MarshalIndent(roles, "", " ")
599- if len(got) == len(want) {659+ if err != nil {
600- for _, r := range volatile[name] {660+ t.Fatal(err)
601- copy(got[r[0]:r[1]], want[r[0]:r[1]])
602- }
603 }661 }
604- if !bytes.Equal(got, want) {662+ if err := os.WriteFile(rolesPath, append(rolesJSON, '\n'), 0644); err != nil {
605- t.Errorf("%s differs from live Xvfb:\n got %x\nwant %x", name, p, want)663+ t.Fatal(err)
606 }664 }
607 return665 return
608 }666 }
667+ rolesJSON, err := os.ReadFile(rolesPath)
668+ if err != nil {
669+ t.Fatal(err)
670+ }
671+ var capturedRoles map[string]uint32
672+ if err := json.Unmarshal(rolesJSON, &capturedRoles); err != nil {
673+ t.Fatal(err)
674+ }
675+ for _, role := range slices.Sorted(maps.Keys(roles)) {
676+ if roles[role] != capturedRoles[role] {
677+ t.Logf("server-chosen %s: live %#x, golden %#x", role, roles[role], capturedRoles[role])
678+ }
679+ }
680+ for name, p := range out {
681+ compareGolden(t, name, p, roles, capturedRoles)
682+ }
683+}
684+
685+var sources = map[string]string{
686+ "req": "# source: Xvfb via raw socketpair tee; independent reference encoder; server accepted\n",
687+ "reply": "# source: Xvfb reply via raw socketpair tee to an independent reference request\n",
688+ "event": "# source: Xvfb event via raw socketpair tee; triggered by an independent second client or reference request\n",
689+ "error": "# source: Xvfb error via raw socketpair tee to an independent reference request\n",
690+}
691+
692+func writeGolden(t *testing.T, name string, p []byte) {
693+ t.Helper()
694+ path := filepath.Join("../../testdata/golden", name+".hex")
609 if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {695 if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
610 t.Fatal(err)696 t.Fatal(err)
611 }697 }
698+ source := sources[name[strings.LastIndexByte(name, '.')+1:]]
612 text := source + "# xtrace fallback: local xtrace is glibc's execution tracer, not X11 xtrace\n" + hex.EncodeToString(p) + "\n"699 text := source + "# xtrace fallback: local xtrace is glibc's execution tracer, not X11 xtrace\n" + hex.EncodeToString(p) + "\n"
613 if err := os.WriteFile(path, []byte(text), 0644); err != nil {700 if err := os.WriteFile(path, []byte(text), 0644); err != nil {
614 t.Fatal(err)701 t.Fatal(err)
615 }702 }
616 }703 }
704+
705+// compareGolden checks a live packet against the committed golden without
706+// depending on the server build: server-chosen fields must hold each side's
707+// own value (live roles, recorded roles) and are masked before an exact
708+// comparison; structural replies are checked for invariants only.
709+func compareGolden(t *testing.T, name string, live []byte, roles, recorded map[string]uint32) {
710+ t.Helper()
711+ want, err := LoadHex(filepath.Join("../../testdata/golden", name+".hex"))
712+ if err != nil {
713+ t.Errorf("%s: %v (run with -update to create it)", name, err)
714+ return
715+ }
716+ if check := structural[name]; check != nil {
717+ if err := check(live, roles); err != nil {
718+ t.Errorf("%s (live): %v", name, err)
719+ }
720+ if err := check(want, recorded); err != nil {
721+ t.Errorf("%s (golden): %v", name, err)
722+ }
723+ return
724+ }
725+ got, exp, err := normalize(name, live, roles), normalize(name, want, recorded), error(nil)
726+ for _, e := range []struct {
727+ side string
728+ err error
729+ }{{"live", got.err}, {"golden", exp.err}} {
730+ if e.err != nil {
731+ t.Errorf("%s (%s): %v", name, e.side, e.err)
732+ err = e.err
733+ }
734+ }
735+ if err == nil && !bytes.Equal(got.b, exp.b) {
736+ t.Errorf("%s differs from live Xvfb after masking server-chosen fields:\n got %x\nwant %x", name, live, want)
737+ }
738+}
739+
740+type normalized struct {
741+ b []byte
742+ err error
743+}
744+
745+// normalize verifies each server-chosen field of p holds roles[Role] and
746+// replaces it with zero bytes.
747+func normalize(name string, p []byte, roles map[string]uint32) normalized {
748+ b := bytes.Clone(p)
749+ for _, f := range serverFields[name] {
750+ if f.Off+f.Size > len(b) {
751+ return normalized{err: fmt.Errorf("field %s at %d beyond %d bytes", f.Role, f.Off, len(b))}
752+ }
753+ if f.Role != "time" {
754+ v, ok := roles[f.Role]
755+ if !ok {
756+ return normalized{err: fmt.Errorf("no recorded value for role %s", f.Role)}
757+ }
758+ var got uint32
759+ if f.Size == 1 {
760+ got = uint32(b[f.Off])
761+ } else {
762+ got = binary.LittleEndian.Uint32(b[f.Off:])
763+ }
764+ if got != v {
765+ return normalized{err: fmt.Errorf("field %s at %d = %#x, want %#x", f.Role, f.Off, got, v)}
766+ }
767+ }
768+ clear(b[f.Off : f.Off+f.Size])
769+ }
770+ return normalized{b: b}
771+}
772+
773+// checkListExtensions: a framed reply whose STR list holds exactly names_len
774+// entries filling the reply, including the extensions xturbo binds.
775+func checkListExtensions(p []byte, _ map[string]uint32) error {
776+ if len(p) < 32 || p[0] != 1 || len(p) != 32+4*int(binary.LittleEndian.Uint32(p[4:])) {
777+ return fmt.Errorf("bad framing")
778+ }
779+ names := map[string]bool{}
780+ off := 32
781+ for range int(p[1]) {
782+ if off >= len(p) || off+1+int(p[off]) > len(p) {
783+ return fmt.Errorf("STR list overruns the reply")
784+ }
785+ names[string(p[off+1:off+1+int(p[off])])] = true
786+ off += 1 + int(p[off])
787+ }
788+ if (off+3)&^3 != len(p) {
789+ return fmt.Errorf("STR list ends at %d, reply has %d bytes", off, len(p))
790+ }
791+ for _, want := range []string{"BIG-REQUESTS", "XC-MISC", "XFIXES", "RENDER", "SHAPE", "Composite", "X-Resource", "Generic Event Extension"} {
792+ if !names[want] {
793+ return fmt.Errorf("missing extension %q", want)
794+ }
795+ }
796+ return nil
797+}
798+
799+// checkQueryPictFormats: declared counts match the list sizes exactly and the
800+// root visual maps to the picture format CreatePicture used.
801+func checkQueryPictFormats(p []byte, roles map[string]uint32) error {
802+ u32 := binary.LittleEndian.Uint32
803+ if len(p) < 32 || p[0] != 1 || len(p) != 32+4*int(u32(p[4:])) {
804+ return fmt.Errorf("bad framing")
805+ }
806+ formats, screens, subpixels := int(u32(p[8:])), int(u32(p[12:])), int(u32(p[24:]))
807+ off := 32 + 28*formats
808+ found := false
809+ for range screens {
810+ if off+8 > len(p) {
811+ return fmt.Errorf("screens overrun")
812+ }
813+ depths := int(u32(p[off:]))
814+ off += 8
815+ for range depths {
816+ if off+8 > len(p) {
817+ return fmt.Errorf("depths overrun")
818+ }
819+ visuals := int(binary.LittleEndian.Uint16(p[off+2:]))
820+ off += 8
821+ for range visuals {
822+ if off+8 > len(p) {
823+ return fmt.Errorf("visuals overrun")
824+ }
825+ if u32(p[off:]) == roles["rootVisual"] && u32(p[off+4:]) == roles["rootFormat"] {
826+ found = true
827+ }
828+ off += 8
829+ }
830+ }
831+ }
832+ off += 4 * subpixels
833+ if off != len(p) {
834+ return fmt.Errorf("lists end at %d, reply has %d bytes", off, len(p))
835+ }
836+ if !found {
837+ return fmt.Errorf("root visual %#x does not map to format %#x", roles["rootVisual"], roles["rootFormat"])
838+ }
839+ return nil
840+}
A testdata/golden/xvfb-roles.json +19 -0
@@ -0,0 +1,19 @@
1+{
2+ "atom": 231,
3+ "badRegion": 2132104,
4+ "badWindow": 2127735,
5+ "base": 2097152,
6+ "child": 2097153,
7+ "client2": 4194305,
8+ "picture": 2097155,
9+ "renderMajor": 139,
10+ "resMajor": 148,
11+ "root": 927,
12+ "rootColormap": 32,
13+ "rootFormat": 41,
14+ "rootVisual": 33,
15+ "sibling": 2097154,
16+ "xfixesError": 140,
17+ "xfixesEvent": 87,
18+ "xfixesMajor": 138
19+}
@@ -0,0 +1,19 @@
1+{
2+ "atom": 231,
3+ "badRegion": 2132104,
4+ "badWindow": 2127735,
5+ "base": 2097152,
6+ "child": 2097153,
7+ "client2": 4194305,
8+ "picture": 2097155,
9+ "renderMajor": 139,
10+ "resMajor": 148,
11+ "root": 927,
12+ "rootColormap": 32,
13+ "rootFormat": 41,
14+ "rootVisual": 33,
15+ "sibling": 2097154,
16+ "xfixesError": 140,
17+ "xfixesEvent": 87,
18+ "xfixesMajor": 138
19+}

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 1

Tags 1

↑↓ moveenter openesc close