package kiosk import ( "context" "os" "os/exec" "path/filepath" "slices" "strings" "testing" ) // writeTree writes files (slash paths relative to root) under root. func writeTree(t *testing.T, root string, files map[string]string) { t.Helper() for rel, body := range files { path := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } } // requireGo skips a test that runs the go command when it is missing. func requireGo(t *testing.T) { t.Helper() if _, err := exec.LookPath("go"); err != nil { t.Skip("go command not found") } } const pluginHead = "package p\n\ntype Plugin struct{}\n\n" // TestReadIdentity reads ID() and Requires() from fixtures of every // supported shape and refuses the rest with the function named. func TestReadIdentity(t *testing.T) { requireGo(t) ctx := context.Background() gomod := "module example.test/p\n\ngo 1.27.0\n" accepted := []struct { name string files map[string]string want Identity }{ {"literal", map[string]string{ "plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" } func (*Plugin) Requires() []string { return []string{"acme.b", "acme.c"} }`, }, Identity{ID: "acme.a", Requires: []string{"acme.b", "acme.c"}}}, {"parenthesized literal", map[string]string{ "plugin.go": pluginHead + `func (Plugin) ID() string { return ("acme.a") }`, }, Identity{ID: "acme.a"}}, {"same-file const", map[string]string{ "plugin.go": pluginHead + `const id = "acme.a" func (*Plugin) ID() string { return id } func (*Plugin) Requires() []string { return nil }`, }, Identity{ID: "acme.a"}}, {"const in another file", map[string]string{ "plugin.go": pluginHead + `func (*Plugin) ID() string { return PluginID }`, "ids.go": "package p\n\nconst PluginID = \"acme.a\"\n", }, Identity{ID: "acme.a"}}, {"chained and concatenated consts", map[string]string{ "plugin.go": pluginHead + `const ( vendor = "acme" name = base base = "a" full = vendor + "." + name ) func (*Plugin) ID() string { return full }`, }, Identity{ID: "acme.a"}}, {"cross-package same module", map[string]string{ "go.mod": gomod, "ids/ids.go": "package ids\n\nconst A = \"acme.a\"\nconst B = \"acme.b\"\n", "plugin.go": pluginHead[:len("package p\n\n")] + `import "example.test/p/ids" type Plugin struct{} func (*Plugin) ID() string { return ids.A } func (*Plugin) Requires() []string { return []string{ids.B} }`, }, Identity{ID: "acme.a", Requires: []string{"acme.b"}}}, {"named import alias", map[string]string{ "go.mod": gomod, "names/ids.go": "package names\n\nconst A = \"acme.a\"\n", "plugin.go": `package p import x "example.test/p/names" type Plugin struct{} func (*Plugin) ID() string { return x.A }`, }, Identity{ID: "acme.a"}}, {"package name differs from path", map[string]string{ "go.mod": gomod, "go-idents/ids.go": "package idents\n\nconst A = \"acme.a\"\n", "plugin.go": `package p import "example.test/p/go-idents" type Plugin struct{} func (*Plugin) ID() string { return idents.A }`, }, Identity{ID: "acme.a"}}, {"requires empty slice", map[string]string{ "plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" } func (*Plugin) Requires() []string { return []string{} }`, }, Identity{ID: "acme.a", Requires: []string{}}}, {"requires mixed literals and consts", map[string]string{ "plugin.go": pluginHead + `const user = "acme.user" func (*Plugin) ID() string { return "acme.a" } func (*Plugin) Requires() []string { return []string{user, "acme.translate"} }`, }, Identity{ID: "acme.a", Requires: []string{"acme.user", "acme.translate"}}}, {"no Requires method", map[string]string{ "plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" }`, }, Identity{ID: "acme.a"}}, {"plugin.go wins over a model's ID", map[string]string{ "a_model.go": "package p\n\ntype Post struct{}\n\nfunc (Post) ID() string { return \"post\" }\nfunc (Post) Requires() []string { return []string{\"x.y\"} }\n", "plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.a" } func (*Plugin) Requires() []string { return []string{"acme.b"} }`, }, Identity{ID: "acme.a", Requires: []string{"acme.b"}}}, {"test files are ignored", map[string]string{ "plugin.go": pluginHead + `func (*Plugin) ID() string { return PluginID }`, "ids.go": "package p\n\nconst PluginID = \"acme.a\"\n", "plugin_test.go": "package p\n\nconst PluginID = \"acme.test\"\n", }, Identity{ID: "acme.a"}}, } for _, tc := range accepted { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() files := map[string]string{"go.mod": gomod} for k, v := range tc.files { files[k] = v } writeTree(t, dir, files) got, err := ReadIdentity(ctx, dir) if err != nil { t.Fatalf("ReadIdentity: %v", err) } if got.ID != tc.want.ID || !slices.Equal(got.Requires, tc.want.Requires) || (got.Requires == nil) != (tc.want.Requires == nil) { t.Errorf("identity = %#v, want %#v", got, tc.want) } }) } t.Run("other module through a replace", func(t *testing.T) { root := t.TempDir() writeTree(t, root, map[string]string{ "dep/go.mod": "module example.test/dep\n\ngo 1.27.0\n", "dep/consts/ids.go": "package consts\n\nconst Shop = \"acme.shop\"\nconst User = \"acme.user\"\n", "plugin/go.mod": "module example.test/p\n\ngo 1.27.0\n\nrequire example.test/dep v0.3.0\n\nreplace example.test/dep => ../dep\n", "plugin/plugin.go": `package p import "example.test/dep/consts" type Plugin struct{} func (*Plugin) ID() string { return consts.Shop } func (*Plugin) Requires() []string { return []string{consts.User} }`, }) got, err := ReadIdentity(ctx, filepath.Join(root, "plugin")) if err != nil { t.Fatalf("ReadIdentity: %v", err) } if got.ID != "acme.shop" || !slices.Equal(got.Requires, []string{"acme.user"}) { t.Errorf("identity = %#v", got) } }) refused := []struct { name, src, want string extra map[string]string keepsID bool }{ {"function call", `func (*Plugin) ID() string { return id() } func id() string { return "acme.x" }`, "ID()", nil, false}, {"var", `var pluginID = "acme.x" func (*Plugin) ID() string { return pluginID }`, "ID()", nil, false}, {"concatenation with a call", `func (*Plugin) ID() string { return "acme." + suffix() } func suffix() string { return "x" }`, "ID()", nil, false}, {"empty ID", `func (*Plugin) ID() string { return "" }`, "empty string", nil, false}, {"no return", `func (*Plugin) ID() string { panic("x") }`, "ID()", nil, false}, {"self-referencing const", `const a = b const b = a func (*Plugin) ID() string { return a }`, "refers to itself", nil, false}, {"const without value", `const ( a = "acme.a" b ) func (*Plugin) ID() string { return b }`, "no explicit value", nil, false}, {"unknown import", `func (*Plugin) ID() string { return ids.A }`, "no import provides package ids", nil, false}, {"import outside the module", "", "no import provides package ids", map[string]string{"plugin.go": `package p import "example.test/elsewhere/ids" type Plugin struct{} func (*Plugin) ID() string { return ids.A }`}, false}, {"missing ID method", `func (*Plugin) Name() string { return "acme.x" }`, "no ID() method", nil, false}, {"requires var", `var deps = []string{"acme.y"} func (*Plugin) ID() string { return "acme.x" } func (*Plugin) Requires() []string { return deps }`, "Requires()", nil, true}, {"requires array", `func (*Plugin) ID() string { return "acme.x" } func (*Plugin) Requires() []string { s := [1]string{"acme.y"}; return s[:] }`, "Requires()", nil, true}, {"requires element var", `var dep = "acme.y" func (*Plugin) ID() string { return "acme.x" } func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()", nil, true}, } for _, tc := range refused { t.Run("refuse "+tc.name, func(t *testing.T) { dir := t.TempDir() files := map[string]string{"go.mod": gomod} if tc.src != "" { files["plugin.go"] = pluginHead + tc.src + "\n" } for k, v := range tc.extra { files[k] = v } writeTree(t, dir, files) got, err := ReadIdentity(ctx, dir) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("err = %v, want one naming %q", err, tc.want) } if tc.keepsID != (got.ID == "acme.x") { t.Errorf("identity = %#v; the ID is kept only when Requires() fails", got) } if tc.name != "missing ID method" && !strings.Contains(err.Error(), dir) { t.Errorf("err = %v, want the plugin directory named", err) } }) } t.Run("not a directory", func(t *testing.T) { if _, err := ReadIdentity(ctx, filepath.Join(t.TempDir(), "missing")); err == nil { t.Error("ReadIdentity of a missing directory succeeded") } empty := t.TempDir() if _, err := ReadIdentity(ctx, empty); err == nil || !strings.Contains(err.Error(), "no Go files") { t.Errorf("empty directory: err = %v", err) } }) } func TestLastElem(t *testing.T) { for in, want := range map[string]string{ "example.test/a/b": "b", "example.test/a/v2": "a", "example.test/go-yaml": "yaml", "v2": "v2", } { if got := lastElem(in); got != want { t.Errorf("lastElem(%q) = %q, want %q", in, got, want) } } }