Files
summercms/internal/build/plugindirs_test.go
Jakub Zych 07673e4f30 fix(34): WR-01 keep summer build working for frameworks before party.Describe
summer build always emitted party.Describe and []party.Info, so an
application whose framework requirement predates v0.6.0 stopped compiling
once the CLI was updated. The build now checks the party package go list
resolves in the application (a static look for Describe and Info, not a
version compare, since a local checkout reports the previous tag until
it is tagged) and, when they are missing, writes main.go and
plugins.gen.go exactly as before, with a warning to move the framework
to v0.6.0. An unresolvable party package keeps the current sources.
2026-10-29 22:03:57 +01:00

369 lines
16 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")
}
})
}
// TestLegacyFramework pins WR-01: an application whose framework has no
// party.Describe (before v0.6.0) gets the sources summer build wrote
// before, without the plugin metadata, so it still compiles.
func TestLegacyFramework(t *testing.T) {
ctx := t.Context()
app := func(t *testing.T, partySrc string) string {
t.Helper()
root := t.TempDir()
fw := filepath.Join(root, "framework")
writeAll(t, filepath.Join(fw, "go.mod"), "module "+frameworkModule+"\n\ngo 1.27.0\n")
writeAll(t, filepath.Join(fw, "modules", "party", "party.go"), partySrc)
writeAll(t, filepath.Join(fw, "modules", "party", "party_test.go"), "package party\n\nfunc Describe() {}\n\ntype Info struct{}\n")
dir := filepath.Join(root, "app")
writeAll(t, filepath.Join(dir, "go.mod"), "module example.test/app\n\ngo 1.27.0\n\nrequire "+frameworkModule+" v0.5.3\n\nreplace "+frameworkModule+" => ../framework\n")
return dir
}
m := Manifest{Module: "example.test/app", Binary: "app", Plugins: []Plugin{{ID: "acme.blog", Module: "example.test/blog"}}}
t.Run("before party.Describe", func(t *testing.T) {
meta, warnings := resolveBuildMeta(ctx, app(t, "package party\n\nfunc Activate() {}\n"), Manifest{Module: "example.test/app", Binary: "app"})
if !meta.Legacy || len(warnings) != 1 || !strings.Contains(warnings[0], "has no party.Describe") || !strings.Contains(warnings[0], "v0.6.0") {
t.Fatalf("meta %+v, warnings %q", meta, warnings)
}
mainSrc, err := generateMainFor(m, !meta.Legacy)
if err != nil {
t.Fatal(err)
}
gen, err := generatePluginsGen(m, meta)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(mainSrc), "party.Describe") || !strings.Contains(string(mainSrc), "party.Activate(app, PluginIDs)") {
t.Errorf("main.go:\n%s", mainSrc)
}
want := "// Code generated by summer build. DO NOT EDIT.\n\npackage main\n\nimport (\n\t_ \"example.test/blog\"\n)\n\n" +
"// PluginIDs is the ordered plugin list from summer.yaml.\nvar PluginIDs = []string{\n\t\"acme.blog\",\n}\n"
if string(gen) != want {
t.Errorf("plugins.gen.go:\n%s\nwant:\n%s", gen, want)
}
})
t.Run("with party.Describe", func(t *testing.T) {
src := "package party\n\ntype Info struct{ ID string }\n\nfunc Describe(any, []Info, string) error { return nil }\n"
meta, warnings := resolveBuildMeta(ctx, app(t, src), Manifest{Module: "example.test/app", Binary: "app"})
if meta.Legacy {
t.Fatalf("meta %+v, warnings %q", meta, warnings)
}
mainSrc, err := generateMainFor(m, !meta.Legacy)
if err != nil || !strings.Contains(string(mainSrc), "party.Describe(app, PluginInfo, FrameworkVersion)") {
t.Errorf("main.go: %v\n%s", err, mainSrc)
}
})
t.Run("unresolvable party package keeps the current sources", func(t *testing.T) {
orig := listPackages
listPackages = func(context.Context, string, []string) ([]listedPackage, error) {
return nil, os.ErrPermission
}
t.Cleanup(func() { listPackages = orig })
if !frameworkDescribes(ctx, t.TempDir()) {
t.Error("a failing go list switched to the legacy sources")
}
})
}