Files
summercms/internal/build/plugindirs_test.go
Jakub Zych 7de774b0b3 test(build): plugin dirs, install resolution and summer.yaml edits
- TestPluginDirs and TestEffectiveFramework over go list JSON fixtures and
  git checkouts; TestResolveInstall against an in-memory registry;
  TestAppendManifestPlugins for the in-place summer.yaml edit
- fix: a package of the application's own module was reported as a
  submodule with the application's git describe; it is in-app (local)
- fix: summer.yaml items written as '-   id:' keep their spacing when
  plugin:add or plugin:install appends to them
2026-10-29 21:22:47 +01:00

304 lines
13 KiB
Go

package build
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/party"
)
// fakeListPackages replaces listPackages with a decoder of the given
// `go list -json` stream for the test.
func fakeListPackages(t *testing.T, stream string) *[]string {
t.Helper()
var asked []string
orig := listPackages
listPackages = func(_ context.Context, _ string, paths []string) ([]listedPackage, error) {
asked = append(asked, paths...)
var out []listedPackage
dec := json.NewDecoder(strings.NewReader(stream))
for dec.More() {
var p listedPackage
if err := dec.Decode(&p); err != nil {
return nil, err
}
out = append(out, p)
}
return out, nil
}
t.Cleanup(func() { listPackages = orig })
return &asked
}
// writeAll writes body to path, creating the parent directories.
func writeAll(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
write(t, path, body)
}
func pluginYAML(id, module, pkg, name string) string {
return "# Generated by summer plugin:manifest. Do not edit; rerun the command.\n" +
"schema: 1\nid: " + id + "\nmodule: " + module + "\npackage: " + pkg + "\n" +
"name: " + name + "\ndescription: " + name + " plugin.\nframework: v0.6.0\n"
}
// TestPluginDirs resolves sources and versions from go list output (as
// JSON fixtures) and from git for plugin directories that are their own
// repositories.
func TestPluginDirs(t *testing.T) {
requireGit(t)
ctx := t.Context()
root := t.TempDir()
dir := func(rel string) string { return filepath.Join(root, filepath.FromSlash(rel)) }
// A plugin from the module cache with a plugin.yaml.
writeAll(t, filepath.Join(dir("cache/user"), "plugin.yaml"), pluginYAML("acme.user", "example.test/sm-user-plugin", "example.test/sm-user-plugin", "User"))
// A nested plugin package of a cached module.
writeAll(t, filepath.Join(dir("cache/pg/subs"), "plugin.yaml"), pluginYAML("acme.subs", "example.test/sm-pg-plugin", "example.test/sm-pg-plugin/subs", "Subs"))
// A submodule (own repository) tagged v0.2.0 and one commit after.
sub := dir("app/plugins/acme/sub")
writeAll(t, filepath.Join(sub, "go.mod"), "module example.test/acme/sub\n\ngo 1.27.0\n")
writeAll(t, filepath.Join(sub, "plugin.yaml"), pluginYAML("acme.sub", "example.test/acme/sub", "example.test/acme/sub", "Sub"))
runTestGit(t, dir("app"), "init", "-q")
runTestGit(t, sub, "init", "-q")
runTestGit(t, sub, "add", ".")
runTestGit(t, sub, "commit", "-q", "-m", "one")
runTestGit(t, sub, "tag", "v0.2.0")
writeAll(t, filepath.Join(sub, "a.go"), "package sub\n")
runTestGit(t, sub, "add", ".")
runTestGit(t, sub, "commit", "-q", "-m", "two")
writeAll(t, filepath.Join(sub, "a.go"), "package sub\n\n// dirty\n")
// An untagged own repository: describe falls back to the commit.
untagged := dir("app/plugins/acme/untagged")
writeAll(t, filepath.Join(untagged, "go.mod"), "module example.test/acme/untagged\n\ngo 1.27.0\n")
runTestGit(t, untagged, "init", "-q")
runTestGit(t, untagged, "add", ".")
runTestGit(t, untagged, "commit", "-q", "-m", "one")
// A directory inside the app repository (not its own top level), with
// a plugin.yaml for another ID.
inapp := dir("app/plugins/acme/inapp")
writeAll(t, filepath.Join(inapp, "plugin.yaml"), pluginYAML("acme.other", "example.test/app", "example.test/app/plugins/acme/inapp", "Other"))
stream := `{"ImportPath":"example.test/sm-user-plugin","Dir":"` + dir("cache/user") + `","Module":{"Path":"example.test/sm-user-plugin","Version":"v0.1.2","Dir":"` + dir("cache/user") + `"}}
{"ImportPath":"example.test/sm-pg-plugin/subs","Dir":"` + dir("cache/pg/subs") + `","Module":{"Path":"example.test/sm-pg-plugin","Version":"v0.3.0","Dir":"` + dir("cache/pg") + `"}}
{"ImportPath":"example.test/replaced","Dir":"` + dir("cache/user") + `","Module":{"Path":"example.test/replaced","Version":"v1.0.0","Replace":{"Path":"example.test/fork","Version":"v1.0.1"}}}
{"ImportPath":"example.test/acme/sub","Dir":"` + sub + `","Module":{"Path":"example.test/acme/sub","Version":"v0.0.0","Dir":"` + sub + `","Replace":{"Path":"./plugins/acme/sub","Dir":"` + sub + `"}}}
{"ImportPath":"example.test/acme/untagged","Dir":"` + untagged + `","Module":{"Path":"example.test/acme/untagged","Main":true,"Dir":"` + untagged + `"}}
{"ImportPath":"example.test/app/plugins/acme/inapp","Dir":"` + inapp + `","Module":{"Path":"example.test/app","Main":true,"Dir":"` + dir("app") + `"}}
{"ImportPath":"example.test/gopath","Dir":"` + inapp + `"}
{"ImportPath":"example.test/broken","Error":{"Err":"cannot find module providing package example.test/broken"}}
`
asked := fakeListPackages(t, stream)
manifest := Manifest{Module: "example.test/app", Binary: "app", Plugins: []Plugin{
{ID: "acme.user", Module: "example.test/sm-user-plugin"},
{ID: "acme.subs", Module: "example.test/sm-pg-plugin/subs"},
{ID: "acme.replaced", Module: "example.test/replaced"},
{ID: "acme.sub", Module: "example.test/acme/sub"},
{ID: "acme.untagged", Module: "example.test/acme/untagged"},
{ID: "acme.inapp", Module: "example.test/app/plugins/acme/inapp"},
{ID: "acme.gopath", Module: "example.test/gopath"},
{ID: "acme.broken", Module: "example.test/broken"},
{ID: "acme.absent", Module: "example.test/absent"},
}}
infos, warnings := resolvePluginInfo(ctx, dir("app"), manifest)
if len(*asked) != len(manifest.Plugins) {
t.Errorf("go list asked for %v", *asked)
}
if len(infos) != len(manifest.Plugins) {
t.Fatalf("infos = %+v", infos)
}
byID := map[string]party.Info{}
for i, info := range infos {
if info.ID != manifest.Plugins[i].ID {
t.Errorf("info %d is %s, want manifest order", i, info.ID)
}
byID[info.ID] = info
}
check := func(id, module, pkg, name, version, source string) {
t.Helper()
got := byID[id]
if got.Module != module || got.Package != pkg || got.Name != name || got.Version != version || got.Source != source {
t.Errorf("%s = %+v, want module %q package %q name %q version %q source %q", id, got, module, pkg, name, version, source)
}
}
check("acme.user", "example.test/sm-user-plugin", "example.test/sm-user-plugin", "User", "v0.1.2", party.SourceModule)
check("acme.subs", "example.test/sm-pg-plugin", "example.test/sm-pg-plugin/subs", "Subs", "v0.3.0", party.SourceModule)
check("acme.replaced", "example.test/replaced", "example.test/replaced", "", "v1.0.1", party.SourceModule)
check("acme.inapp", "example.test/app", "example.test/app/plugins/acme/inapp", "", "", party.SourceLocal)
check("acme.gopath", "", "example.test/gopath", "", "", party.SourceLocal)
check("acme.broken", "", "", "", "", "")
check("acme.absent", "", "", "", "", "")
s := byID["acme.sub"]
if s.Source != party.SourceSubmodule || !strings.HasPrefix(s.Version, "v0.2.0-1-g") || strings.Contains(s.Version, "dirty") || s.Name != "Sub" {
t.Errorf("submodule = %+v, want git describe v0.2.0-1-g… without -dirty", s)
}
u := byID["acme.untagged"]
if u.Source != party.SourceSubmodule || len(u.Version) < 7 || strings.HasPrefix(u.Version, "v") {
t.Errorf("untagged own repository = %+v, want the abbreviated commit", u)
}
joined := strings.Join(warnings, "\n")
for _, want := range []string{
"acme.inapp: plugin.yaml describes acme.other, not acme.inapp; ignored",
"acme.broken: cannot find module providing package example.test/broken",
"acme.absent: package not found",
"acme.replaced: plugin.yaml describes acme.user, not acme.replaced; ignored",
"acme.gopath: plugin.yaml describes acme.other, not acme.gopath; ignored",
} {
if !strings.Contains(joined, want) {
t.Errorf("warnings = %q, want %q", warnings, want)
}
}
if len(warnings) != 5 {
t.Errorf("warnings = %q, want 5", warnings)
}
for _, info := range infos {
for _, v := range []string{info.Module, info.Package, info.Version, info.Name, info.Description} {
if strings.Contains(v, root) {
t.Errorf("a filesystem path leaked into %+v", info)
}
}
}
t.Run("go list failure keeps the IDs", func(t *testing.T) {
orig := listPackages
listPackages = func(context.Context, string, []string) ([]listedPackage, error) {
return nil, os.ErrPermission
}
t.Cleanup(func() { listPackages = orig })
infos, warnings := resolvePluginInfo(ctx, dir("app"), manifest)
if len(infos) != len(manifest.Plugins) || infos[0].ID != "acme.user" || infos[0].Version != "" || len(warnings) != 1 ||
!strings.Contains(warnings[0], "IDs only") {
t.Errorf("infos %+v, warnings %q", infos, warnings)
}
})
t.Run("no plugins", func(t *testing.T) {
infos, warnings := resolvePluginInfo(ctx, dir("app"), Manifest{})
if len(infos) != 0 || len(warnings) != 0 {
t.Errorf("infos %+v, warnings %q", infos, warnings)
}
})
t.Run("own repository", func(t *testing.T) {
if !isOwnRepository(ctx, sub) {
t.Error("a submodule-like checkout is not its own repository")
}
if isOwnRepository(ctx, inapp) {
t.Error("a directory inside the app repository counts as its own repository")
}
if isOwnRepository(ctx, t.TempDir()) {
t.Error("a directory outside any repository counts as its own repository")
}
})
}
// TestEffectiveFramework covers where the framework version comes from:
// go.mod require, go.mod replace (version or directory), go.work use and
// replace, and the warning when git cannot describe the checkout.
func TestEffectiveFramework(t *testing.T) {
requireGit(t)
ctx := t.Context()
tagged := func(tags ...string) string {
t.Helper()
fw := t.TempDir()
writeAll(t, filepath.Join(fw, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n")
runTestGit(t, fw, "init", "-q")
for i, tag := range tags {
writeAll(t, filepath.Join(fw, "f.go"), "package summercms\n\n// "+tag+"\n")
runTestGit(t, fw, "add", ".")
runTestGit(t, fw, "commit", "-q", "-m", "c"+string(rune('0'+i)))
runTestGit(t, fw, "tag", tag)
}
return fw
}
fwOld := tagged("v0.5.3")
fwNew := tagged("v0.5.3", "v0.6.0")
app := func(gomod, gowork string) string {
t.Helper()
parent := t.TempDir()
dir := filepath.Join(parent, "app")
writeAll(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\n"+gomod)
if gowork != "" {
writeAll(t, filepath.Join(dir, "go.work"), "go 1.27.0\n\nuse .\n\n"+gowork)
}
return dir
}
req := "require " + frameworkModule + " v0.5.0\n"
cases := []struct {
name, gomod, gowork, want string
}{
{"require only", req, "", "v0.5.0"},
{"go.mod replace with a version", req + "\nreplace " + frameworkModule + " => example.test/fork v0.5.9\n", "", "v0.5.9"},
{"go.mod replace with a directory", req + "\nreplace " + frameworkModule + " => " + fwOld + "\n", "", "v0.5.3"},
{"go.work replace wins over go.mod", req + "\nreplace " + frameworkModule + " => " + fwOld + "\n", "replace " + frameworkModule + " => " + fwNew + "\n", "v0.6.0"},
{"go.work use of the framework", req, "use " + fwNew + "\n", "v0.6.0"},
{"go.work without the framework", req, "", "v0.5.0"},
}
for _, c := range cases {
got, err := EffectiveFramework(ctx, app(c.gomod, c.gowork))
if err != nil || got != c.want {
t.Errorf("%s: EffectiveFramework = %q, %v; want %q", c.name, got, err, c.want)
}
}
t.Run("relative replace", func(t *testing.T) {
parent := t.TempDir()
fw := filepath.Join(parent, "framework")
if err := os.Rename(fwNew, fw); err != nil {
t.Fatal(err)
}
dir := filepath.Join(parent, "app")
writeAll(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\n"+req+"\nreplace "+frameworkModule+" => ../framework\n")
if got, err := EffectiveFramework(ctx, dir); err != nil || got != "v0.6.0" {
t.Errorf("EffectiveFramework = %q, %v", got, err)
}
})
t.Run("untagged checkout warns", func(t *testing.T) {
fw := tagged()
writeAll(t, filepath.Join(fw, "f.go"), "package summercms\n")
runTestGit(t, fw, "add", ".")
runTestGit(t, fw, "commit", "-q", "-m", "untagged")
got, err := EffectiveFramework(ctx, app(req+"\nreplace "+frameworkModule+" => "+fw+"\n", ""))
if got != "v0.5.0" || err == nil || !strings.Contains(err.Error(), "using the go.mod require v0.5.0") {
t.Errorf("EffectiveFramework = %q, %v; want the require and a warning", got, err)
}
})
t.Run("git missing", func(t *testing.T) {
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("go not found")
}
bin := t.TempDir()
if err := os.Symlink(goBin, filepath.Join(bin, "go")); err != nil {
t.Fatal(err)
}
dir := app(req+"\nreplace "+frameworkModule+" => "+fwOld+"\n", "")
t.Setenv("PATH", bin)
got, err := EffectiveFramework(ctx, dir)
if got != "v0.5.0" || err == nil || !strings.Contains(err.Error(), "git describe") {
t.Errorf("EffectiveFramework without git = %q, %v; want the require and a warning", got, err)
}
})
t.Run("no go.mod", func(t *testing.T) {
if _, err := EffectiveFramework(ctx, t.TempDir()); err == nil {
t.Error("EffectiveFramework without go.mod succeeded")
}
})
}