v0.1.0 1a6c6c9

sync_test.go RawHistoryFolder

1package main
2
3import (
4	"bytes"
5	"fmt"
6	"os"
7	"path/filepath"
8	"reflect"
9	"sort"
10	"strings"
11	"testing"
12
13	"github.com/bnema/go-wayland-bindings/spec"
14)
15
16// fakeFetcher serves source trees from memory; no network or git.
17type fakeFetcher struct {
18	// trees[source id][ref] = repository files.
19	trees map[string]map[string]map[string]string
20	// latest[source id] = ref used when no pin is given.
21	latest map[string]string
22}
23
24func (f *fakeFetcher) commit(id, ref string) string {
25	return fmt.Sprintf("%040x", len(id)*1000+len(ref)) // deterministic, 40 chars
26}
27
28func (f *fakeFetcher) Resolve(src upstream, pin string) (string, string, error) {
29	ref := pin
30	if ref == "" {
31		ref = f.latest[src.ID]
32	}
33	if _, ok := f.trees[src.ID][ref]; !ok {
34		return "", "", fmt.Errorf("fake: %s has no ref %q", src.ID, ref)
35	}
36	return ref, f.commit(src.ID, ref), nil
37}
38
39func (f *fakeFetcher) Checkout(src upstream, ref, commit, dir string) error {
40	files, ok := f.trees[src.ID][ref]
41	if !ok {
42		return fmt.Errorf("fake: %s has no ref %q", src.ID, ref)
43	}
44	for name, content := range files {
45		p := filepath.Join(dir, filepath.FromSlash(name))
46		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
47			return err
48		}
49		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
50			return err
51		}
52	}
53	return nil
54}
55
56// xmlDoc builds a protocol document. Each interface is "name:version" followed
57// by optional requests "req(arg:type[:iface],...)".
58func xmlDoc(name string, copyright bool, ifaces ...string) string {
59	var b strings.Builder
60	fmt.Fprintf(&b, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<protocol name=%q>\n", name)
61	if copyright {
62		b.WriteString("  <copyright>Copyright test</copyright>\n")
63	}
64	for _, spec := range ifaces {
65		parts := strings.Split(spec, "|")
66		nv := strings.SplitN(parts[0], ":", 2)
67		fmt.Fprintf(&b, "  <interface name=%q version=%q>\n", nv[0], nv[1])
68		for _, req := range parts[1:] {
69			open := strings.Index(req, "(")
70			fmt.Fprintf(&b, "    <request name=%q>\n", req[:open])
71			args := strings.TrimSuffix(req[open+1:], ")")
72			if args != "" {
73				for _, a := range strings.Split(args, ",") {
74					f := strings.Split(a, ":")
75					if len(f) == 3 {
76						fmt.Fprintf(&b, "      <arg name=%q type=%q interface=%q/>\n", f[0], f[1], f[2])
77					} else {
78						fmt.Fprintf(&b, "      <arg name=%q type=%q/>\n", f[0], f[1])
79					}
80				}
81			}
82			b.WriteString("    </request>\n")
83		}
84		b.WriteString("  </interface>\n")
85	}
86	b.WriteString("</protocol>\n")
87	return b.String()
88}
89
90func testSources() []upstream {
91	var out []upstream
92	for _, s := range defaultSources() {
93		if s.ID == "wayland" || s.ID == "wayland-protocols" {
94			out = append(out, s)
95		}
96	}
97	return out
98}
99
100func waylandTree() map[string]string {
101	return map[string]string{
102		"protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:6", "wl_seat:9"),
103		"protocol/other.xml":   "ignored: not a vendored path",
104	}
105}
106
107func baseFetcher() *fakeFetcher {
108	return &fakeFetcher{
109		trees: map[string]map[string]map[string]string{
110			"wayland": {"1.0.0": waylandTree()},
111			"wayland-protocols": {
112				"1.0": {
113					"stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true,
114						"xdg_wm_base:5|get_surface(id:new_id:xdg_surface,surface:object:wl_surface)",
115						"xdg_surface:5"),
116					"staging/foo/foo-v1.xml": xmlDoc("foo", true, "foo_manager_v1:1|make(seat:object:wl_seat)"),
117					"stable/tests/skip.xml":  xmlDoc("skip", false),
118					"experimental/x/xx.xml":  xmlDoc("xx", false),
119					"unstable/old/old-unstable-v1.xml": xmlDoc("old", true,
120						"old_thing_v1:1|poke(n:uint)"),
121					"README.md": "not xml",
122				},
123			},
124		},
125		latest: map[string]string{"wayland": "1.0.0", "wayland-protocols": "1.0"},
126	}
127}
128
129func syncOnce(t *testing.T, root string, f Fetcher, o options) (string, error) {
130	t.Helper()
131	o.root = root
132	var out bytes.Buffer
133	err := run(o, testSources(), f, &out)
134	return strings.TrimSpace(out.String()), err
135}
136
137func mustSync(t *testing.T, root string, f Fetcher, o options) string {
138	t.Helper()
139	out, err := syncOnce(t, root, f, o)
140	if err != nil {
141		t.Fatal(err)
142	}
143	return out
144}
145
146func snapshot(t *testing.T, root string) map[string]string {
147	t.Helper()
148	out := map[string]string{}
149	err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
150		if err != nil || info.IsDir() {
151			return err
152		}
153		b, err := os.ReadFile(p)
154		out[p] = string(b)
155		return err
156	})
157	if err != nil {
158		t.Fatal(err)
159	}
160	return out
161}
162
163func readNotes(t *testing.T, path string) string {
164	t.Helper()
165	b, err := os.ReadFile(path)
166	if err != nil {
167		t.Fatal(err)
168	}
169	return string(b)
170}
171
172func loadRoot(t *testing.T, root string) *spec.Manifest {
173	t.Helper()
174	m, err := spec.LoadFS(os.DirFS(filepath.Join(root, "spec")))
175	if err != nil {
176		t.Fatal(err)
177	}
178	return m
179}
180
181func TestInitialSync(t *testing.T) {
182	root := t.TempDir()
183	f := baseFetcher()
184	if got := mustSync(t, root, f, options{}); got != "changed=true" {
185		t.Fatalf("output %q", got)
186	}
187	m := loadRoot(t, root)
188
189	var pkgs []string
190	for _, e := range m.Protocols {
191		pkgs = append(pkgs, e.Package)
192	}
193	if want := []string{"foo", "old", "wayland", "xdgshell"}; !reflect.DeepEqual(pkgs, want) {
194		t.Fatalf("packages = %v, want %v", pkgs, want)
195	}
196
197	xdg, _ := m.Package("xdgshell")
198	if xdg.Source != "wayland-protocols" || xdg.Path != "stable/xdg-shell/xdg-shell.xml" || xdg.License != "MIT" {
199		t.Errorf("xdgshell = %+v", xdg)
200	}
201	if want := []string{"wayland"}; !reflect.DeepEqual(xdg.Deps, want) {
202		t.Errorf("xdgshell deps = %v", xdg.Deps)
203	}
204	if want := []spec.InterfaceEntry{{Name: "xdg_surface", Version: 5}, {Name: "xdg_wm_base", Version: 5}}; !reflect.DeepEqual(xdg.Interfaces, want) {
205		t.Errorf("xdgshell interfaces = %v", xdg.Interfaces)
206	}
207	if w, _ := m.Package("wayland"); len(w.Deps) != 0 || w.Deps == nil {
208		t.Errorf("wayland deps = %#v, want empty non-nil", w.Deps)
209	}
210	if len(m.Sources) != 2 || m.Sources[0].ID != "wayland" || m.Sources[1].Ref != "1.0" {
211		t.Errorf("sources = %+v", m.Sources)
212	}
213
214	// Bytes are copied unchanged, and the sha256 describes them.
215	got, err := os.ReadFile(filepath.Join(root, "spec", "xml", "xdgshell.xml"))
216	if err != nil {
217		t.Fatal(err)
218	}
219	if string(got) != f.trees["wayland-protocols"]["1.0"]["stable/xdg-shell/xdg-shell.xml"] {
220		t.Error("xml bytes differ from upstream")
221	}
222	if len(xdg.SHA256) != 64 {
223		t.Errorf("sha256 = %q", xdg.SHA256)
224	}
225	if _, err := os.Stat(filepath.Join(root, "spec", "xml", "skip.xml")); err == nil {
226		t.Error("tests/ file was vendored")
227	}
228
229	// Second run: nothing changes.
230	before := snapshot(t, root)
231	if got := mustSync(t, root, f, options{}); got != "changed=false" {
232		t.Errorf("rerun output %q", got)
233	}
234	if !reflect.DeepEqual(before, snapshot(t, root)) {
235		t.Error("rerun modified spec/")
236	}
237}
238
239func TestLicenseServerDecoration(t *testing.T) {
240	var src []upstream
241	for _, s := range defaultSources() {
242		if s.ID == "plasma-wayland-protocols" {
243			src = append(src, s)
244		}
245	}
246	f := &fakeFetcher{
247		trees: map[string]map[string]map[string]string{"plasma-wayland-protocols": {"v1.0.0": {
248			"src/protocols/server-decoration.xml": xmlDoc("server_decoration", true, "org_kde_kwin_server_decoration:1"),
249		}}},
250		latest: map[string]string{"plasma-wayland-protocols": "v1.0.0"},
251	}
252	root := t.TempDir()
253	if err := run(options{root: root}, src, f, &bytes.Buffer{}); err != nil {
254		t.Fatal(err)
255	}
256	e, ok := loadRoot(t, root).Package("serverdecoration")
257	if !ok || e.License != "LGPL-2.1-or-later" {
258		t.Errorf("entry = %+v, %v", e, ok)
259	}
260}
261
262func TestCheckWritesNothing(t *testing.T) {
263	root := t.TempDir()
264	f := baseFetcher()
265
266	// Empty tree: check reports a change but creates nothing.
267	if got := mustSync(t, root, f, options{check: true}); got != "changed=true" {
268		t.Errorf("output %q", got)
269	}
270	if len(snapshot(t, root)) != 0 {
271		t.Errorf("-check wrote files: %v", snapshot(t, root))
272	}
273
274	mustSync(t, root, f, options{})
275	if got := mustSync(t, root, f, options{check: true}); got != "changed=false" {
276		t.Errorf("output %q", got)
277	}
278
279	// Upstream moves on; -check reports it and leaves everything alone,
280	// while -notes still works.
281	f.trees["wayland-protocols"]["1.1"] = map[string]string{
282		"stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true, "xdg_wm_base:6", "xdg_surface:5"),
283		"staging/foo/foo-v1.xml":         f.trees["wayland-protocols"]["1.0"]["staging/foo/foo-v1.xml"],
284	}
285	f.latest["wayland-protocols"] = "1.1"
286	before := snapshot(t, root)
287	notesFile := filepath.Join(t.TempDir(), "notes.md")
288	if got := mustSync(t, root, f, options{check: true, notes: notesFile}); got != "changed=true" {
289		t.Errorf("output %q", got)
290	}
291	if !reflect.DeepEqual(before, snapshot(t, root)) {
292		t.Error("-check modified spec/")
293	}
294	if n := readNotes(t, notesFile); !strings.Contains(n, "xdg_wm_base v5 → v6") {
295		t.Errorf("notes missing bump:\n%s", n)
296	}
297}
298
299func TestNotes(t *testing.T) {
300	root := t.TempDir()
301	f := baseFetcher()
302	mustSync(t, root, f, options{})
303
304	// New upstream release:
305	//  - xdg_wm_base version bump, request get_surface keeps its args
306	//  - xdg_popup interface added
307	//  - foo-v2 replaces foo-v1 (major replacement)
308	//  - old package removed
309	//  - new package "bar" added
310	//  - wayland: new tag, identical content (only the source ref changes)
311	f.trees["wayland-protocols"]["1.1"] = map[string]string{
312		"stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true,
313			"xdg_wm_base:6|get_surface(id:new_id:xdg_surface,surface:object:wl_surface)",
314			"xdg_surface:5",
315			"xdg_popup:5"),
316		"staging/foo/foo-v1.xml": f.trees["wayland-protocols"]["1.0"]["staging/foo/foo-v1.xml"],
317		"staging/foo/foo-v2.xml": xmlDoc("foo", true, "foo_manager_v2:1|make(seat:object:wl_seat)"),
318		"staging/bar/bar-v1.xml": xmlDoc("bar", true, "bar_manager_v1:1"),
319	}
320	f.trees["wayland"]["1.0.1"] = map[string]string{
321		"protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:6", "wl_seat:9"),
322	}
323	f.latest["wayland-protocols"] = "1.1"
324	f.latest["wayland"] = "1.0.1"
325
326	notesFile := filepath.Join(t.TempDir(), "notes.md")
327	mustSync(t, root, f, options{notes: notesFile})
328	n := readNotes(t, notesFile)
329
330	for _, want := range []string{
331		"## BREAKING",
332		"- old: package removed",
333		"- foo: major replacement, wayland-protocols:staging/foo/foo-v1.xml → wayland-protocols:staging/foo/foo-v2.xml",
334		"- foo: interface foo_manager_v1 removed",
335		"## Sources",
336		"- wayland-protocols: 1.0 → 1.1",
337		"- wayland: 1.0.0 → 1.0.1",
338		"## Added packages",
339		"- `bar`",
340		"## Removed packages",
341		"- `old`",
342		"## New interfaces",
343		"- xdgshell: xdg_popup (v5)",
344		"## Interface version bumps",
345		"- xdgshell: xdg_wm_base v5 → v6",
346	} {
347		if !strings.Contains(n, want) {
348			t.Errorf("notes missing %q:\n%s", want, n)
349		}
350	}
351	if strings.Contains(n, "get_surface") {
352		t.Errorf("unchanged message reported:\n%s", n)
353	}
354	// Stale XML removed, new XML present.
355	if _, err := os.Stat(filepath.Join(root, "spec", "xml", "old.xml")); err == nil {
356		t.Error("stale old.xml not removed")
357	}
358	if _, err := os.Stat(filepath.Join(root, "spec", "xml", "bar.xml")); err != nil {
359		t.Error("bar.xml missing")
360	}
361	m := loadRoot(t, root)
362	if foo, _ := m.Package("foo"); foo.Path != "staging/foo/foo-v2.xml" {
363		t.Errorf("foo path = %s", foo.Path)
364	}
365
366	// Rerun against the same upstream: zero changes, notes say so.
367	if got := mustSync(t, root, f, options{notes: notesFile}); got != "changed=false" {
368		t.Errorf("rerun output %q", got)
369	}
370	if n := readNotes(t, notesFile); !strings.Contains(n, "No changes.") {
371		t.Errorf("notes for unchanged sync:\n%s", n)
372	}
373}
374
375func TestBreakingMessageChanges(t *testing.T) {
376	root := t.TempDir()
377	f := baseFetcher()
378	mustSync(t, root, f, options{})
379
380	f.trees["wayland-protocols"]["1.1"] = map[string]string{
381		"stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true,
382			// get_surface: arg 2 interface changes; surface type kept.
383			"xdg_wm_base:5|get_surface(id:new_id:xdg_surface,surface:object:wl_seat)|extra(a:int)",
384			// xdg_surface unchanged.
385			"xdg_surface:5"),
386		"staging/foo/foo-v1.xml": xmlDoc("foo", true,
387			// make: arg count changes.
388			"foo_manager_v1:1|make(seat:object:wl_seat,n:uint)"),
389		"unstable/old/old-unstable-v1.xml": xmlDoc("old", true,
390			// poke removed, arg type of nothing; new request added.
391			"old_thing_v1:1|prod(n:uint)"),
392	}
393	f.latest["wayland-protocols"] = "1.1"
394	notesFile := filepath.Join(t.TempDir(), "notes.md")
395	mustSync(t, root, f, options{notes: notesFile})
396	n := readNotes(t, notesFile)
397	for _, want := range []string{
398		"- xdgshell: xdg_wm_base request get_surface arguments changed: #2 surface:object<wl_surface> → surface:object<wl_seat>",
399		"- foo: foo_manager_v1 request make arguments changed: 1 → 2 arguments",
400		"- old: old_thing_v1 request poke removed",
401	} {
402		if !strings.Contains(n, want) {
403			t.Errorf("notes missing %q:\n%s", want, n)
404		}
405	}
406	if strings.Contains(n, "request extra") || strings.Contains(n, "prod") {
407		t.Errorf("added requests must not be breaking:\n%s", n)
408	}
409}
410
411func TestSourceMoveOnlyIsNotAChange(t *testing.T) {
412	root := t.TempDir()
413	f := baseFetcher()
414	mustSync(t, root, f, options{})
415	before := snapshot(t, root)
416
417	// New tags, byte-identical XML.
418	f.trees["wayland"]["1.0.1"] = f.trees["wayland"]["1.0.0"]
419	f.trees["wayland-protocols"]["1.1"] = f.trees["wayland-protocols"]["1.0"]
420	f.latest["wayland"] = "1.0.1"
421	f.latest["wayland-protocols"] = "1.1"
422
423	for _, o := range []options{{}, {check: true}} {
424		if got := mustSync(t, root, f, o); got != "changed=false" {
425			t.Errorf("options %+v: output %q, want changed=false", o, got)
426		}
427		if !reflect.DeepEqual(before, snapshot(t, root)) {
428			t.Errorf("options %+v: spec/ was rewritten", o)
429		}
430	}
431
432	// A real XML change afterwards rewrites the manifest with the new refs.
433	f.trees["wayland"]["1.0.2"] = map[string]string{
434		"protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:7", "wl_seat:9"),
435	}
436	f.latest["wayland"] = "1.0.2"
437	if got := mustSync(t, root, f, options{}); got != "changed=true" {
438		t.Fatalf("output %q", got)
439	}
440	m := loadRoot(t, root)
441	for _, s := range m.Sources {
442		if s.ID == "wayland-protocols" && s.Ref != "1.1" {
443			t.Errorf("wayland-protocols ref = %s, want 1.1", s.Ref)
444		}
445	}
446}
447
448func TestBreakingEnumsAndAttributes(t *testing.T) {
449	enum := func(name string, entries ...string) spec.Enum {
450		e := spec.Enum{Name: name}
451		for _, en := range entries {
452			e.Entries = append(e.Entries, spec.Entry{Name: en})
453		}
454		return e
455	}
456	iface := func(version int, enums []spec.Enum, reqs ...spec.Message) *spec.Protocol {
457		return &spec.Protocol{Interfaces: []spec.Interface{{Name: "a", Version: version, Enums: enums, Requests: reqs}}}
458	}
459	msg := func(typ string, args ...spec.Arg) spec.Message {
460		return spec.Message{Name: "m", Type: typ, Args: args}
461	}
462	cases := []struct {
463		name     string
464		old, cur *spec.Protocol
465		want     string // substring of the single breaking entry; "" means none
466	}{
467		{"enum removed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, nil), "enum e removed"},
468		{"entry removed", iface(1, []spec.Enum{enum("e", "x", "y")}), iface(1, []spec.Enum{enum("e", "x")}), "enum e entry y removed or renamed"},
469		{"entry renamed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "z")}), "enum e entry x removed or renamed"},
470		{"entry added", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "x", "y")}), ""},
471		{"enum added", iface(1, nil), iface(1, []spec.Enum{enum("e", "x")}), ""},
472		{"version decreased", iface(3, nil), iface(2, nil), "interface a version decreased v3 → v2"},
473		{"version increased", iface(2, nil), iface(3, nil), ""},
474		{"destructor added", iface(1, nil, msg("")), iface(1, nil, msg("destructor")), "request m destructor flag changed: false → true"},
475		{"destructor removed", iface(1, nil, msg("destructor")), iface(1, nil, msg("")), "request m destructor flag changed: true → false"},
476		{"enum attr changed",
477			iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})),
478			iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "f"})),
479			"#1 x:uint enum=e → x:uint enum=f"},
480		{"enum attr dropped",
481			iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})),
482			iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint"})),
483			"arguments changed"},
484		{"allow-null changed",
485			iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b"})),
486			iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b", AllowNull: true})),
487			"#1 x:object<b> → x:object<b> allow-null"},
488		{"identical",
489			iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})),
490			iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})),
491			""},
492	}
493	for _, c := range cases {
494		t.Run(c.name, func(t *testing.T) {
495			n := &notes{}
496			diffProtocol(n, "p", c.old, c.cur)
497			switch {
498			case c.want == "" && len(n.breaking) != 0:
499				t.Errorf("unexpected breaking: %v", n.breaking)
500			case c.want != "" && (len(n.breaking) != 1 || !strings.Contains(n.breaking[0], c.want)):
501				t.Errorf("breaking = %v, want one containing %q", n.breaking, c.want)
502			}
503		})
504	}
505}
506
507func TestBreakingEventAndRename(t *testing.T) {
508	old := &spec.Protocol{Interfaces: []spec.Interface{{
509		Name:   "a",
510		Events: []spec.Message{{Name: "e", Args: []spec.Arg{{Name: "x", Type: "int"}}}},
511	}}}
512	cur := &spec.Protocol{Interfaces: []spec.Interface{{
513		Name:   "a",
514		Events: []spec.Message{{Name: "e", Args: []spec.Arg{{Name: "y", Type: "int"}}}, {Name: "f"}},
515	}}}
516	n := &notes{}
517	diffProtocol(n, "p", old, cur)
518	if len(n.breaking) != 1 || !strings.Contains(n.breaking[0], "event e arguments changed: #1 x:int → y:int") {
519		t.Errorf("breaking = %v", n.breaking)
520	}
521	n = &notes{}
522	diffProtocol(n, "p", cur, old)
523	if len(n.breaking) != 2 || !strings.Contains(n.breaking[1], "event f removed") {
524		t.Errorf("breaking = %v", n.breaking)
525	}
526}
527
528// symlinkFetcher checks out a repository whose vendored path is a symlink.
529type symlinkFetcher struct{ target string }
530
531func (f symlinkFetcher) Resolve(upstream, string) (string, string, error) { return "", "", nil }
532
533func (f symlinkFetcher) Checkout(_ upstream, _, _, dir string) error {
534	if err := os.MkdirAll(filepath.Join(dir, "protocol"), 0o755); err != nil {
535		return err
536	}
537	return os.Symlink(f.target, filepath.Join(dir, "protocol", "wayland.xml"))
538}
539
540func TestScanSkipsNonRegularFiles(t *testing.T) {
541	secret := filepath.Join(t.TempDir(), "secret.xml")
542	if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil {
543		t.Fatal(err)
544	}
545	found, err := checkoutAndScan(testSources()[0], "1.0.0", "c", symlinkFetcher{target: secret})
546	if err != nil {
547		t.Skipf("symlinks unsupported: %v", err)
548	}
549	if len(found) != 0 {
550		t.Errorf("symlink was scanned: %+v", found)
551	}
552}
553
554func TestSelectionRule(t *testing.T) {
555	cases := []struct {
556		name  string
557		files map[string]string
558		want  string // selected path
559	}{
560		{
561			name: "stable beats staging beats unstable",
562			files: map[string]string{
563				"stable/s/foo.xml":               xmlDoc("a", true, "a_s:1"),
564				"staging/s/foo-v3.xml":           xmlDoc("b", true, "b_s:1"),
565				"unstable/s/foo-unstable-v4.xml": xmlDoc("c", true, "c_s:1"),
566			},
567			want: "stable/s/foo.xml",
568		},
569		{
570			name: "higher major wins within a stability",
571			files: map[string]string{
572				"staging/s/foo-v1.xml":  xmlDoc("a", true, "a_s:1"),
573				"staging/s/foo-v2.xml":  xmlDoc("b", true, "b_s:1"),
574				"staging/s/foo-v10.xml": xmlDoc("c", true, "c_s:1"),
575			},
576			want: "staging/s/foo-v10.xml",
577		},
578		{
579			name: "no suffix counts as v1",
580			files: map[string]string{
581				"staging/s/foo.xml":    xmlDoc("a", true, "a_s:1"),
582				"staging/s/foo-v2.xml": xmlDoc("b", true, "b_s:1"),
583			},
584			want: "staging/s/foo-v2.xml",
585		},
586		{
587			name: "unstable suffix dropped before version",
588			files: map[string]string{
589				"unstable/s/foo-unstable-v1.xml": xmlDoc("a", true, "a_s:1"),
590				"staging/s/foo-v1.xml":           xmlDoc("b", true, "b_s:1"),
591			},
592			want: "staging/s/foo-v1.xml",
593		},
594	}
595	for _, tc := range cases {
596		t.Run(tc.name, func(t *testing.T) {
597			f := &fakeFetcher{
598				trees:  map[string]map[string]map[string]string{"wayland-protocols": {"1.0": tc.files}},
599				latest: map[string]string{"wayland-protocols": "1.0"},
600			}
601			root := t.TempDir()
602			src := testSources()[1:]
603			if err := run(options{root: root}, src, f, &bytes.Buffer{}); err != nil {
604				t.Fatal(err)
605			}
606			e, ok := loadRoot(t, root).Package("foo")
607			if !ok || e.Path != tc.want {
608				t.Errorf("selected %q, want %q", e.Path, tc.want)
609			}
610		})
611	}
612}
613
614func TestCollisions(t *testing.T) {
615	cases := []struct {
616		name    string
617		files   map[string]string
618		wantErr string
619	}{
620		{
621			name: "tie on stability and major",
622			files: map[string]string{
623				"stable/a/foo-v1.xml": xmlDoc("a", true, "a_s:1"),
624				"stable/b/foo.xml":    xmlDoc("b", true, "b_s:1"),
625			},
626			wantErr: `package "foo"`,
627		},
628		{
629			name: "same interface in two packages",
630			files: map[string]string{
631				"stable/a/foo.xml": xmlDoc("a", true, "shared:1"),
632				"stable/b/bar.xml": xmlDoc("b", true, "shared:1"),
633			},
634			wantErr: `interface "shared" is defined by both`,
635		},
636		{
637			name: "missing copyright",
638			files: map[string]string{
639				"stable/a/foo.xml": xmlDoc("a", false, "a_s:1"),
640			},
641			wantErr: "missing <copyright>",
642		},
643		{
644			name: "unparsable xml",
645			files: map[string]string{
646				"stable/a/foo.xml": "<protocol",
647			},
648			wantErr: "foo.xml",
649		},
650		{
651			name: "unknown referenced interface",
652			files: map[string]string{
653				"stable/a/foo.xml": xmlDoc("a", true, "a_s:1|r(x:object:ghost)"),
654			},
655			wantErr: `unknown interface "ghost"`,
656		},
657	}
658	for _, tc := range cases {
659		t.Run(tc.name, func(t *testing.T) {
660			f := &fakeFetcher{
661				trees:  map[string]map[string]map[string]string{"wayland-protocols": {"1.0": tc.files}},
662				latest: map[string]string{"wayland-protocols": "1.0"},
663			}
664			root := t.TempDir()
665			err := run(options{root: root}, testSources()[1:], f, &bytes.Buffer{})
666			if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
667				t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
668			}
669			if len(snapshot(t, root)) != 0 {
670				t.Error("files written despite error")
671			}
672		})
673	}
674}
675
676func TestErrorsLeaveSpecUntouched(t *testing.T) {
677	root := t.TempDir()
678	f := baseFetcher()
679	mustSync(t, root, f, options{})
680	before := snapshot(t, root)
681
682	f.trees["wayland-protocols"]["1.1"] = map[string]string{
683		"stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", false, "xdg_wm_base:5"),
684	}
685	f.latest["wayland-protocols"] = "1.1"
686	if _, err := syncOnce(t, root, f, options{}); err == nil {
687		t.Fatal("expected error")
688	}
689	if !reflect.DeepEqual(before, snapshot(t, root)) {
690		t.Error("spec/ modified after failed sync")
691	}
692
693	// Unknown ref is an error too.
694	if _, err := syncOnce(t, root, f, options{pinFile: writePins(t, `{"wayland":"9.9.9"}`)}); err == nil {
695		t.Error("expected error for unknown pin")
696	}
697}
698
699func writePins(t *testing.T, content string) string {
700	t.Helper()
701	p := filepath.Join(t.TempDir(), "pins.json")
702	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
703		t.Fatal(err)
704	}
705	return p
706}
707
708func TestPinFile(t *testing.T) {
709	root := t.TempDir()
710	f := baseFetcher()
711	f.trees["wayland-protocols"]["1.1"] = map[string]string{
712		"stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true, "xdg_wm_base:6"),
713	}
714	f.latest["wayland-protocols"] = "1.1"
715
716	// Pin to the older ref although 1.1 is latest.
717	mustSync(t, root, f, options{pinFile: writePins(t, `{"wayland-protocols":"1.0"}`)})
718	m := loadRoot(t, root)
719	if m.Sources[1].Ref != "1.0" {
720		t.Errorf("ref = %s, want pinned 1.0", m.Sources[1].Ref)
721	}
722	if e, _ := m.Package("xdgshell"); e.Interfaces[0].Version != 5 && e.Interfaces[1].Version != 5 {
723		t.Errorf("pinned content not used: %+v", e)
724	}
725
726	// Without the pin the latest is used.
727	mustSync(t, root, f, options{})
728	if got := loadRoot(t, root).Sources[1].Ref; got != "1.1" {
729		t.Errorf("ref = %s, want 1.1", got)
730	}
731
732	if _, err := syncOnce(t, root, f, options{pinFile: writePins(t, `{"nope":"1"}`)}); err == nil || !strings.Contains(err.Error(), "unknown source") {
733		t.Errorf("unknown source error = %v", err)
734	}
735	if _, err := syncOnce(t, root, f, options{pinFile: writePins(t, `[`)}); err == nil {
736		t.Error("expected decode error")
737	}
738}
739
740func TestInitialPinsFile(t *testing.T) {
741	pins, err := loadPins("testdata/initial-pins.json", defaultSources())
742	if err != nil {
743		t.Fatal(err)
744	}
745	want := map[string]string{
746		"wayland":                  "1.26.0",
747		"wayland-protocols":        "1.49",
748		"wlr-protocols":            "bf4fc79abc359eea5a0edec0ac6d4a2b2955f82a",
749		"wlroots":                  "0.20.2",
750		"plasma-wayland-protocols": "v1.23.0",
751	}
752	if !reflect.DeepEqual(pins, want) {
753		t.Errorf("pins = %v", pins)
754	}
755}
756
757func TestNoXMLFound(t *testing.T) {
758	f := &fakeFetcher{
759		trees:  map[string]map[string]map[string]string{"wayland": {"1.0.0": {"README": "x"}}},
760		latest: map[string]string{"wayland": "1.0.0"},
761	}
762	err := run(options{root: t.TempDir()}, testSources()[:1], f, &bytes.Buffer{})
763	if err == nil || !strings.Contains(err.Error(), "no protocol XML") {
764		t.Errorf("error = %v", err)
765	}
766}
767
768func TestSourceTable(t *testing.T) {
769	got := map[string]upstream{}
770	for _, s := range defaultSources() {
771		got[s.ID] = s
772	}
773	urls := map[string]string{
774		"wayland":                  "https://gitlab.freedesktop.org/wayland/wayland.git",
775		"wayland-protocols":        "https://gitlab.freedesktop.org/wayland/wayland-protocols.git",
776		"wlr-protocols":            "https://gitlab.freedesktop.org/wlroots/wlr-protocols.git",
777		"wlroots":                  "https://gitlab.freedesktop.org/wlroots/wlroots.git",
778		"plasma-wayland-protocols": "https://invent.kde.org/libraries/plasma-wayland-protocols.git",
779	}
780	for id, url := range urls {
781		if got[id].URL != url {
782			t.Errorf("%s url = %q", id, got[id].URL)
783		}
784	}
785	if len(got) != len(urls) {
786		t.Errorf("%d sources", len(got))
787	}
788	if s := got["wlr-protocols"]; s.Kind != PinBranch || s.Branch != "master" {
789		t.Errorf("wlr-protocols = %+v", s)
790	}
791
792	type m struct {
793		id, path string
794		ok       bool
795		st       Stability
796	}
797	for _, c := range []m{
798		{"wayland", "protocol/wayland.xml", true, Stable},
799		{"wayland", "protocol/other.xml", false, 0},
800		{"wayland-protocols", "stable/xdg-shell/xdg-shell.xml", true, Stable},
801		{"wayland-protocols", "staging/xdg-dialog/xdg-dialog-v1.xml", true, Staging},
802		{"wayland-protocols", "unstable/tablet/tablet-unstable-v2.xml", true, Unstable},
803		{"wayland-protocols", "stable/tests/x.xml", false, 0},
804		{"wayland-protocols", "staging/experimental/x.xml", false, 0},
805		{"wayland-protocols", "experimental/xx-shell/xx-shell-v1.xml", false, 0},
806		{"wayland-protocols", "stable/xdg-shell/README.md", false, 0},
807		{"wlr-protocols", "unstable/wlr-layer-shell-unstable-v1.xml", true, Unstable},
808		{"wlr-protocols", "unstable/sub/x.xml", false, 0},
809		{"wlr-protocols", "stable/x.xml", false, 0},
810		{"wlroots", "protocol/input-method-unstable-v2.xml", true, Unstable},
811		{"wlroots", "protocol/virtual-keyboard-unstable-v1.xml", true, Unstable},
812		{"wlroots", "protocol/wlr-layer-shell-unstable-v1.xml", false, 0},
813		{"plasma-wayland-protocols", "src/protocols/server-decoration.xml", true, Unstable},
814		{"plasma-wayland-protocols", "src/protocols/idle.xml", false, 0},
815	} {
816		st, ok := got[c.id].Match(c.path)
817		if ok != c.ok || st != c.st {
818			t.Errorf("%s Match(%q) = %v,%v want %v,%v", c.id, c.path, st, ok, c.st, c.ok)
819		}
820	}
821}
822
823func TestTagSelection(t *testing.T) {
824	tags := map[string]string{}
825	for _, tg := range []string{"1.2.0", "1.10.0", "1.10.90", "1.11.91", "1.11.1", "1.9.0", "1.9.95", "1.10.0-rc1", "v1.12.0", "1.27.0-rc1"} {
826		tags[tg] = "sha-" + tg
827	}
828	var wayland, protocols, plasma upstream
829	for _, s := range defaultSources() {
830		switch s.ID {
831		case "wayland":
832			wayland = s
833		case "wayland-protocols":
834			protocols = s
835		case "plasma-wayland-protocols":
836			plasma = s
837		}
838	}
839	// Only X.Y.Z with Z < 90: 1.10.90, 1.11.91 and 1.9.95 are release
840	// candidates; 1.11.1 is accepted.
841	if got, err := latestTag(wayland, tags); err != nil || got != "1.11.1" {
842		t.Errorf("wayland latest = %q, %v", got, err)
843	}
844	delete(tags, "1.11.1")
845	if got, _ := latestTag(wayland, tags); got != "1.10.0" {
846		t.Errorf("wayland latest = %q", got)
847	}
848	for tag, want := range map[string]bool{
849		"1.26.0": true, "1.3.89": true, "1.26.90": false, "1.26.91": false, "1.4.91": false,
850		"1.4": false, "1.4.0.1": false, "1.4.x": false,
851	} {
852		if got := waylandTagOK(tag); got != want {
853			t.Errorf("waylandTagOK(%q) = %v, want %v", tag, got, want)
854		}
855	}
856
857	if got, err := latestTag(protocols, map[string]string{"1.9": "a", "1.10": "b", "1.49": "c", "1.49.1": "d", "v1.50": "e"}); err != nil || got != "1.49" {
858		t.Errorf("wayland-protocols latest = %q, %v", got, err)
859	}
860	if got, err := latestTag(plasma, map[string]string{"v1.9.0": "a", "v1.23.0": "b", "1.99.0": "c"}); err != nil || got != "v1.23.0" {
861		t.Errorf("plasma latest = %q, %v", got, err)
862	}
863	if _, err := latestTag(plasma, map[string]string{"1.0.0": "a"}); err == nil {
864		t.Error("expected error when no tag matches")
865	}
866}
867
868func TestParseLsRemote(t *testing.T) {
869	out := strings.Join([]string{
870		"aaaa\trefs/heads/master",
871		"bbbb\trefs/tags/1.0",
872		"cccc\trefs/tags/1.0^{}",
873		"dddd\trefs/tags/1.1",
874		"",
875	}, "\n")
876	tags, heads := parseLsRemote(out)
877	if want := map[string]string{"1.0": "cccc", "1.1": "dddd"}; !reflect.DeepEqual(tags, want) {
878		t.Errorf("tags = %v (annotated tags must resolve to the peeled commit)", tags)
879	}
880	if want := map[string]string{"master": "aaaa"}; !reflect.DeepEqual(heads, want) {
881		t.Errorf("heads = %v", heads)
882	}
883	// Peeled line before the tag object line.
884	tags, _ = parseLsRemote("cccc\trefs/tags/2.0^{}\nbbbb\trefs/tags/2.0")
885	if tags["2.0"] != "cccc" {
886		t.Errorf("tags = %v", tags)
887	}
888}
889
890func TestFileMajorAndVersions(t *testing.T) {
891	for in, want := range map[string]int{
892		"foo.xml": 1, "foo-v2.xml": 2, "stable/x/foo-unstable-v3.xml": 3, "a-v12.xml": 12, "v2-thing.xml": 1,
893	} {
894		if got := fileMajor(in); got != want {
895			t.Errorf("fileMajor(%q) = %d, want %d", in, got, want)
896		}
897	}
898	vs := []string{"1.10", "1.2", "1.9"}
899	sort.Slice(vs, func(i, j int) bool { return compareVersions(vs[i], vs[j]) < 0 })
900	if !reflect.DeepEqual(vs, []string{"1.2", "1.9", "1.10"}) {
901		t.Errorf("sorted = %v", vs)
902	}
903	if compareVersions("v1.2.3", "1.2.3") != 0 {
904		t.Error("v prefix should be ignored")
905	}
906}

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