package main import ( "bytes" "fmt" "os" "path/filepath" "reflect" "sort" "strings" "testing" "github.com/bnema/go-wayland-bindings/spec" ) // fakeFetcher serves source trees from memory; no network or git. type fakeFetcher struct { // trees[source id][ref] = repository files. trees map[string]map[string]map[string]string // latest[source id] = ref used when no pin is given. latest map[string]string } func (f *fakeFetcher) commit(id, ref string) string { return fmt.Sprintf("%040x", len(id)*1000+len(ref)) // deterministic, 40 chars } func (f *fakeFetcher) Resolve(src upstream, pin string) (string, string, error) { ref := pin if ref == "" { ref = f.latest[src.ID] } if _, ok := f.trees[src.ID][ref]; !ok { return "", "", fmt.Errorf("fake: %s has no ref %q", src.ID, ref) } return ref, f.commit(src.ID, ref), nil } func (f *fakeFetcher) Checkout(src upstream, ref, commit, dir string) error { files, ok := f.trees[src.ID][ref] if !ok { return fmt.Errorf("fake: %s has no ref %q", src.ID, ref) } for name, content := range files { p := filepath.Join(dir, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { return err } } return nil } // xmlDoc builds a protocol document. Each interface is "name:version" followed // by optional requests "req(arg:type[:iface],...)". func xmlDoc(name string, copyright bool, ifaces ...string) string { var b strings.Builder fmt.Fprintf(&b, "\n\n", name) if copyright { b.WriteString(" Copyright test\n") } for _, spec := range ifaces { parts := strings.Split(spec, "|") nv := strings.SplitN(parts[0], ":", 2) fmt.Fprintf(&b, " \n", nv[0], nv[1]) for _, req := range parts[1:] { open := strings.Index(req, "(") fmt.Fprintf(&b, " \n", req[:open]) args := strings.TrimSuffix(req[open+1:], ")") if args != "" { for _, a := range strings.Split(args, ",") { f := strings.Split(a, ":") if len(f) == 3 { fmt.Fprintf(&b, " \n", f[0], f[1], f[2]) } else { fmt.Fprintf(&b, " \n", f[0], f[1]) } } } b.WriteString(" \n") } b.WriteString(" \n") } b.WriteString("\n") return b.String() } func testSources() []upstream { var out []upstream for _, s := range defaultSources() { if s.ID == "wayland" || s.ID == "wayland-protocols" { out = append(out, s) } } return out } func waylandTree() map[string]string { return map[string]string{ "protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:6", "wl_seat:9"), "protocol/other.xml": "ignored: not a vendored path", } } func baseFetcher() *fakeFetcher { return &fakeFetcher{ trees: map[string]map[string]map[string]string{ "wayland": {"1.0.0": waylandTree()}, "wayland-protocols": { "1.0": { "stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true, "xdg_wm_base:5|get_surface(id:new_id:xdg_surface,surface:object:wl_surface)", "xdg_surface:5"), "staging/foo/foo-v1.xml": xmlDoc("foo", true, "foo_manager_v1:1|make(seat:object:wl_seat)"), "stable/tests/skip.xml": xmlDoc("skip", false), "experimental/x/xx.xml": xmlDoc("xx", false), "unstable/old/old-unstable-v1.xml": xmlDoc("old", true, "old_thing_v1:1|poke(n:uint)"), "README.md": "not xml", }, }, }, latest: map[string]string{"wayland": "1.0.0", "wayland-protocols": "1.0"}, } } func syncOnce(t *testing.T, root string, f Fetcher, o options) (string, error) { t.Helper() o.root = root var out bytes.Buffer err := run(o, testSources(), f, &out) return strings.TrimSpace(out.String()), err } func mustSync(t *testing.T, root string, f Fetcher, o options) string { t.Helper() out, err := syncOnce(t, root, f, o) if err != nil { t.Fatal(err) } return out } func snapshot(t *testing.T, root string) map[string]string { t.Helper() out := map[string]string{} err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } b, err := os.ReadFile(p) out[p] = string(b) return err }) if err != nil { t.Fatal(err) } return out } func readNotes(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(b) } func loadRoot(t *testing.T, root string) *spec.Manifest { t.Helper() m, err := spec.LoadFS(os.DirFS(filepath.Join(root, "spec"))) if err != nil { t.Fatal(err) } return m } func TestInitialSync(t *testing.T) { root := t.TempDir() f := baseFetcher() if got := mustSync(t, root, f, options{}); got != "changed=true" { t.Fatalf("output %q", got) } m := loadRoot(t, root) var pkgs []string for _, e := range m.Protocols { pkgs = append(pkgs, e.Package) } if want := []string{"foo", "old", "wayland", "xdgshell"}; !reflect.DeepEqual(pkgs, want) { t.Fatalf("packages = %v, want %v", pkgs, want) } xdg, _ := m.Package("xdgshell") if xdg.Source != "wayland-protocols" || xdg.Path != "stable/xdg-shell/xdg-shell.xml" || xdg.License != "MIT" { t.Errorf("xdgshell = %+v", xdg) } if want := []string{"wayland"}; !reflect.DeepEqual(xdg.Deps, want) { t.Errorf("xdgshell deps = %v", xdg.Deps) } if want := []spec.InterfaceEntry{{Name: "xdg_surface", Version: 5}, {Name: "xdg_wm_base", Version: 5}}; !reflect.DeepEqual(xdg.Interfaces, want) { t.Errorf("xdgshell interfaces = %v", xdg.Interfaces) } if w, _ := m.Package("wayland"); len(w.Deps) != 0 || w.Deps == nil { t.Errorf("wayland deps = %#v, want empty non-nil", w.Deps) } if len(m.Sources) != 2 || m.Sources[0].ID != "wayland" || m.Sources[1].Ref != "1.0" { t.Errorf("sources = %+v", m.Sources) } // Bytes are copied unchanged, and the sha256 describes them. got, err := os.ReadFile(filepath.Join(root, "spec", "xml", "xdgshell.xml")) if err != nil { t.Fatal(err) } if string(got) != f.trees["wayland-protocols"]["1.0"]["stable/xdg-shell/xdg-shell.xml"] { t.Error("xml bytes differ from upstream") } if len(xdg.SHA256) != 64 { t.Errorf("sha256 = %q", xdg.SHA256) } if _, err := os.Stat(filepath.Join(root, "spec", "xml", "skip.xml")); err == nil { t.Error("tests/ file was vendored") } // Second run: nothing changes. before := snapshot(t, root) if got := mustSync(t, root, f, options{}); got != "changed=false" { t.Errorf("rerun output %q", got) } if !reflect.DeepEqual(before, snapshot(t, root)) { t.Error("rerun modified spec/") } } func TestLicenseServerDecoration(t *testing.T) { var src []upstream for _, s := range defaultSources() { if s.ID == "plasma-wayland-protocols" { src = append(src, s) } } f := &fakeFetcher{ trees: map[string]map[string]map[string]string{"plasma-wayland-protocols": {"v1.0.0": { "src/protocols/server-decoration.xml": xmlDoc("server_decoration", true, "org_kde_kwin_server_decoration:1"), }}}, latest: map[string]string{"plasma-wayland-protocols": "v1.0.0"}, } root := t.TempDir() if err := run(options{root: root}, src, f, &bytes.Buffer{}); err != nil { t.Fatal(err) } e, ok := loadRoot(t, root).Package("serverdecoration") if !ok || e.License != "LGPL-2.1-or-later" { t.Errorf("entry = %+v, %v", e, ok) } } func TestCheckWritesNothing(t *testing.T) { root := t.TempDir() f := baseFetcher() // Empty tree: check reports a change but creates nothing. if got := mustSync(t, root, f, options{check: true}); got != "changed=true" { t.Errorf("output %q", got) } if len(snapshot(t, root)) != 0 { t.Errorf("-check wrote files: %v", snapshot(t, root)) } mustSync(t, root, f, options{}) if got := mustSync(t, root, f, options{check: true}); got != "changed=false" { t.Errorf("output %q", got) } // Upstream moves on; -check reports it and leaves everything alone, // while -notes still works. f.trees["wayland-protocols"]["1.1"] = map[string]string{ "stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true, "xdg_wm_base:6", "xdg_surface:5"), "staging/foo/foo-v1.xml": f.trees["wayland-protocols"]["1.0"]["staging/foo/foo-v1.xml"], } f.latest["wayland-protocols"] = "1.1" before := snapshot(t, root) notesFile := filepath.Join(t.TempDir(), "notes.md") if got := mustSync(t, root, f, options{check: true, notes: notesFile}); got != "changed=true" { t.Errorf("output %q", got) } if !reflect.DeepEqual(before, snapshot(t, root)) { t.Error("-check modified spec/") } if n := readNotes(t, notesFile); !strings.Contains(n, "xdg_wm_base v5 → v6") { t.Errorf("notes missing bump:\n%s", n) } } func TestNotes(t *testing.T) { root := t.TempDir() f := baseFetcher() mustSync(t, root, f, options{}) // New upstream release: // - xdg_wm_base version bump, request get_surface keeps its args // - xdg_popup interface added // - foo-v2 replaces foo-v1 (major replacement) // - old package removed // - new package "bar" added // - wayland: new tag, identical content (only the source ref changes) f.trees["wayland-protocols"]["1.1"] = map[string]string{ "stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true, "xdg_wm_base:6|get_surface(id:new_id:xdg_surface,surface:object:wl_surface)", "xdg_surface:5", "xdg_popup:5"), "staging/foo/foo-v1.xml": f.trees["wayland-protocols"]["1.0"]["staging/foo/foo-v1.xml"], "staging/foo/foo-v2.xml": xmlDoc("foo", true, "foo_manager_v2:1|make(seat:object:wl_seat)"), "staging/bar/bar-v1.xml": xmlDoc("bar", true, "bar_manager_v1:1"), } f.trees["wayland"]["1.0.1"] = map[string]string{ "protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:6", "wl_seat:9"), } f.latest["wayland-protocols"] = "1.1" f.latest["wayland"] = "1.0.1" notesFile := filepath.Join(t.TempDir(), "notes.md") mustSync(t, root, f, options{notes: notesFile}) n := readNotes(t, notesFile) for _, want := range []string{ "## BREAKING", "- old: package removed", "- foo: major replacement, wayland-protocols:staging/foo/foo-v1.xml → wayland-protocols:staging/foo/foo-v2.xml", "- foo: interface foo_manager_v1 removed", "## Sources", "- wayland-protocols: 1.0 → 1.1", "- wayland: 1.0.0 → 1.0.1", "## Added packages", "- `bar`", "## Removed packages", "- `old`", "## New interfaces", "- xdgshell: xdg_popup (v5)", "## Interface version bumps", "- xdgshell: xdg_wm_base v5 → v6", } { if !strings.Contains(n, want) { t.Errorf("notes missing %q:\n%s", want, n) } } if strings.Contains(n, "get_surface") { t.Errorf("unchanged message reported:\n%s", n) } // Stale XML removed, new XML present. if _, err := os.Stat(filepath.Join(root, "spec", "xml", "old.xml")); err == nil { t.Error("stale old.xml not removed") } if _, err := os.Stat(filepath.Join(root, "spec", "xml", "bar.xml")); err != nil { t.Error("bar.xml missing") } m := loadRoot(t, root) if foo, _ := m.Package("foo"); foo.Path != "staging/foo/foo-v2.xml" { t.Errorf("foo path = %s", foo.Path) } // Rerun against the same upstream: zero changes, notes say so. if got := mustSync(t, root, f, options{notes: notesFile}); got != "changed=false" { t.Errorf("rerun output %q", got) } if n := readNotes(t, notesFile); !strings.Contains(n, "No changes.") { t.Errorf("notes for unchanged sync:\n%s", n) } } func TestBreakingMessageChanges(t *testing.T) { root := t.TempDir() f := baseFetcher() mustSync(t, root, f, options{}) f.trees["wayland-protocols"]["1.1"] = map[string]string{ "stable/xdg-shell/xdg-shell.xml": xmlDoc("xdg_shell", true, // get_surface: arg 2 interface changes; surface type kept. "xdg_wm_base:5|get_surface(id:new_id:xdg_surface,surface:object:wl_seat)|extra(a:int)", // xdg_surface unchanged. "xdg_surface:5"), "staging/foo/foo-v1.xml": xmlDoc("foo", true, // make: arg count changes. "foo_manager_v1:1|make(seat:object:wl_seat,n:uint)"), "unstable/old/old-unstable-v1.xml": xmlDoc("old", true, // poke removed, arg type of nothing; new request added. "old_thing_v1:1|prod(n:uint)"), } f.latest["wayland-protocols"] = "1.1" notesFile := filepath.Join(t.TempDir(), "notes.md") mustSync(t, root, f, options{notes: notesFile}) n := readNotes(t, notesFile) for _, want := range []string{ "- xdgshell: xdg_wm_base request get_surface arguments changed: #2 surface:object → surface:object", "- foo: foo_manager_v1 request make arguments changed: 1 → 2 arguments", "- old: old_thing_v1 request poke removed", } { if !strings.Contains(n, want) { t.Errorf("notes missing %q:\n%s", want, n) } } if strings.Contains(n, "request extra") || strings.Contains(n, "prod") { t.Errorf("added requests must not be breaking:\n%s", n) } } func TestSourceMoveOnlyIsNotAChange(t *testing.T) { root := t.TempDir() f := baseFetcher() mustSync(t, root, f, options{}) before := snapshot(t, root) // New tags, byte-identical XML. f.trees["wayland"]["1.0.1"] = f.trees["wayland"]["1.0.0"] f.trees["wayland-protocols"]["1.1"] = f.trees["wayland-protocols"]["1.0"] f.latest["wayland"] = "1.0.1" f.latest["wayland-protocols"] = "1.1" for _, o := range []options{{}, {check: true}} { if got := mustSync(t, root, f, o); got != "changed=false" { t.Errorf("options %+v: output %q, want changed=false", o, got) } if !reflect.DeepEqual(before, snapshot(t, root)) { t.Errorf("options %+v: spec/ was rewritten", o) } } // A real XML change afterwards rewrites the manifest with the new refs. f.trees["wayland"]["1.0.2"] = map[string]string{ "protocol/wayland.xml": xmlDoc("wayland", true, "wl_surface:7", "wl_seat:9"), } f.latest["wayland"] = "1.0.2" if got := mustSync(t, root, f, options{}); got != "changed=true" { t.Fatalf("output %q", got) } m := loadRoot(t, root) for _, s := range m.Sources { if s.ID == "wayland-protocols" && s.Ref != "1.1" { t.Errorf("wayland-protocols ref = %s, want 1.1", s.Ref) } } } func TestBreakingEnumsAndAttributes(t *testing.T) { enum := func(name string, entries ...string) spec.Enum { e := spec.Enum{Name: name} for _, en := range entries { e.Entries = append(e.Entries, spec.Entry{Name: en}) } return e } iface := func(version int, enums []spec.Enum, reqs ...spec.Message) *spec.Protocol { return &spec.Protocol{Interfaces: []spec.Interface{{Name: "a", Version: version, Enums: enums, Requests: reqs}}} } msg := func(typ string, args ...spec.Arg) spec.Message { return spec.Message{Name: "m", Type: typ, Args: args} } cases := []struct { name string old, cur *spec.Protocol want string // substring of the single breaking entry; "" means none }{ {"enum removed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, nil), "enum e removed"}, {"entry removed", iface(1, []spec.Enum{enum("e", "x", "y")}), iface(1, []spec.Enum{enum("e", "x")}), "enum e entry y removed or renamed"}, {"entry renamed", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "z")}), "enum e entry x removed or renamed"}, {"entry added", iface(1, []spec.Enum{enum("e", "x")}), iface(1, []spec.Enum{enum("e", "x", "y")}), ""}, {"enum added", iface(1, nil), iface(1, []spec.Enum{enum("e", "x")}), ""}, {"version decreased", iface(3, nil), iface(2, nil), "interface a version decreased v3 → v2"}, {"version increased", iface(2, nil), iface(3, nil), ""}, {"destructor added", iface(1, nil, msg("")), iface(1, nil, msg("destructor")), "request m destructor flag changed: false → true"}, {"destructor removed", iface(1, nil, msg("destructor")), iface(1, nil, msg("")), "request m destructor flag changed: true → false"}, {"enum attr changed", iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})), iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "f"})), "#1 x:uint enum=e → x:uint enum=f"}, {"enum attr dropped", iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint", Enum: "e"})), iface(1, nil, msg("", spec.Arg{Name: "x", Type: "uint"})), "arguments changed"}, {"allow-null changed", iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b"})), iface(1, nil, msg("", spec.Arg{Name: "x", Type: "object", Interface: "b", AllowNull: true})), "#1 x:object → x:object allow-null"}, {"identical", iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})), iface(1, []spec.Enum{enum("e", "x")}, msg("destructor", spec.Arg{Name: "x", Type: "uint", Enum: "e", AllowNull: true})), ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { n := ¬es{} diffProtocol(n, "p", c.old, c.cur) switch { case c.want == "" && len(n.breaking) != 0: t.Errorf("unexpected breaking: %v", n.breaking) case c.want != "" && (len(n.breaking) != 1 || !strings.Contains(n.breaking[0], c.want)): t.Errorf("breaking = %v, want one containing %q", n.breaking, c.want) } }) } } func TestBreakingEventAndRename(t *testing.T) { old := &spec.Protocol{Interfaces: []spec.Interface{{ Name: "a", Events: []spec.Message{{Name: "e", Args: []spec.Arg{{Name: "x", Type: "int"}}}}, }}} cur := &spec.Protocol{Interfaces: []spec.Interface{{ Name: "a", Events: []spec.Message{{Name: "e", Args: []spec.Arg{{Name: "y", Type: "int"}}}, {Name: "f"}}, }}} n := ¬es{} diffProtocol(n, "p", old, cur) if len(n.breaking) != 1 || !strings.Contains(n.breaking[0], "event e arguments changed: #1 x:int → y:int") { t.Errorf("breaking = %v", n.breaking) } n = ¬es{} diffProtocol(n, "p", cur, old) if len(n.breaking) != 2 || !strings.Contains(n.breaking[1], "event f removed") { t.Errorf("breaking = %v", n.breaking) } } // symlinkFetcher checks out a repository whose vendored path is a symlink. type symlinkFetcher struct{ target string } func (f symlinkFetcher) Resolve(upstream, string) (string, string, error) { return "", "", nil } func (f symlinkFetcher) Checkout(_ upstream, _, _, dir string) error { if err := os.MkdirAll(filepath.Join(dir, "protocol"), 0o755); err != nil { return err } return os.Symlink(f.target, filepath.Join(dir, "protocol", "wayland.xml")) } func TestScanSkipsNonRegularFiles(t *testing.T) { secret := filepath.Join(t.TempDir(), "secret.xml") if err := os.WriteFile(secret, []byte("secret"), 0o644); err != nil { t.Fatal(err) } found, err := checkoutAndScan(testSources()[0], "1.0.0", "c", symlinkFetcher{target: secret}) if err != nil { t.Skipf("symlinks unsupported: %v", err) } if len(found) != 0 { t.Errorf("symlink was scanned: %+v", found) } } func TestSelectionRule(t *testing.T) { cases := []struct { name string files map[string]string want string // selected path }{ { name: "stable beats staging beats unstable", files: map[string]string{ "stable/s/foo.xml": xmlDoc("a", true, "a_s:1"), "staging/s/foo-v3.xml": xmlDoc("b", true, "b_s:1"), "unstable/s/foo-unstable-v4.xml": xmlDoc("c", true, "c_s:1"), }, want: "stable/s/foo.xml", }, { name: "higher major wins within a stability", files: map[string]string{ "staging/s/foo-v1.xml": xmlDoc("a", true, "a_s:1"), "staging/s/foo-v2.xml": xmlDoc("b", true, "b_s:1"), "staging/s/foo-v10.xml": xmlDoc("c", true, "c_s:1"), }, want: "staging/s/foo-v10.xml", }, { name: "no suffix counts as v1", files: map[string]string{ "staging/s/foo.xml": xmlDoc("a", true, "a_s:1"), "staging/s/foo-v2.xml": xmlDoc("b", true, "b_s:1"), }, want: "staging/s/foo-v2.xml", }, { name: "unstable suffix dropped before version", files: map[string]string{ "unstable/s/foo-unstable-v1.xml": xmlDoc("a", true, "a_s:1"), "staging/s/foo-v1.xml": xmlDoc("b", true, "b_s:1"), }, want: "staging/s/foo-v1.xml", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := &fakeFetcher{ trees: map[string]map[string]map[string]string{"wayland-protocols": {"1.0": tc.files}}, latest: map[string]string{"wayland-protocols": "1.0"}, } root := t.TempDir() src := testSources()[1:] if err := run(options{root: root}, src, f, &bytes.Buffer{}); err != nil { t.Fatal(err) } e, ok := loadRoot(t, root).Package("foo") if !ok || e.Path != tc.want { t.Errorf("selected %q, want %q", e.Path, tc.want) } }) } } func TestCollisions(t *testing.T) { cases := []struct { name string files map[string]string wantErr string }{ { name: "tie on stability and major", files: map[string]string{ "stable/a/foo-v1.xml": xmlDoc("a", true, "a_s:1"), "stable/b/foo.xml": xmlDoc("b", true, "b_s:1"), }, wantErr: `package "foo"`, }, { name: "same interface in two packages", files: map[string]string{ "stable/a/foo.xml": xmlDoc("a", true, "shared:1"), "stable/b/bar.xml": xmlDoc("b", true, "shared:1"), }, wantErr: `interface "shared" is defined by both`, }, { name: "missing copyright", files: map[string]string{ "stable/a/foo.xml": xmlDoc("a", false, "a_s:1"), }, wantErr: "missing ", }, { name: "unparsable xml", files: map[string]string{ "stable/a/foo.xml": "