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": "