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.
369 lines
16 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|