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") } }) }