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 := ¬es{}
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 := ¬es{}
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 = ¬es{}
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}