From 7de774b0b398076f53f6daa01cb59df42d3310bd Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 29 Oct 2026 21:22:47 +0100 Subject: [PATCH] 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 --- internal/build/install_test.go | 379 +++++++++++++++++++++++++ internal/build/manifest.go | 16 +- internal/build/manifest_append_test.go | 129 +++++++++ internal/build/plugindirs.go | 13 +- internal/build/plugindirs_test.go | 303 ++++++++++++++++++++ 5 files changed, 830 insertions(+), 10 deletions(-) create mode 100644 internal/build/install_test.go create mode 100644 internal/build/manifest_append_test.go create mode 100644 internal/build/plugindirs_test.go diff --git a/internal/build/install_test.go b/internal/build/install_test.go new file mode 100644 index 00000000..0642b1f1 --- /dev/null +++ b/internal/build/install_test.go @@ -0,0 +1,379 @@ +package build + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.golem15.com/golem15/summercms/modules/kiosk" +) + +// memRegistry is an in-memory Registry that counts its calls. +type memRegistry struct { + mu sync.Mutex + versions map[string][]kiosk.RegistryVersion + details map[string]kiosk.RegistryPluginDetail + calls []string + fail error +} + +func (r *memRegistry) List(context.Context, string, string) (kiosk.ListResponse, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, "list") + return kiosk.ListResponse{}, r.fail +} + +func (r *memRegistry) Plugin(_ context.Context, id string) (kiosk.RegistryPluginDetail, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, "plugin "+id) + if r.fail != nil { + return kiosk.RegistryPluginDetail{}, r.fail + } + d, ok := r.details[id] + if !ok { + return d, fmt.Errorf("%s is %w at mem", id, kiosk.ErrNotFound) + } + return d, nil +} + +func (r *memRegistry) Versions(_ context.Context, id string) ([]kiosk.RegistryVersion, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, "versions "+id) + if r.fail != nil { + return nil, r.fail + } + v, ok := r.versions[id] + if !ok { + return nil, fmt.Errorf("%s is %w at mem", id, kiosk.ErrNotFound) + } + return v, nil +} + +// add lists id with releases given as version:framework pairs, all in +// module example.test/, requiring reqs at every release. +func (r *memRegistry) add(id string, reqs kiosk.Requires, releases ...string) { + mod := "example.test/" + pluginName(id) + r.addIn(id, mod, mod, reqs, releases...) +} + +func (r *memRegistry) addIn(id, mod, pkg string, reqs kiosk.Requires, releases ...string) { + if r.versions == nil { + r.versions = map[string][]kiosk.RegistryVersion{} + r.details = map[string]kiosk.RegistryPluginDetail{} + } + if _, ok := r.versions[id]; !ok { + r.versions[id] = []kiosk.RegistryVersion{} + } + for _, rel := range releases { + v, fw, _ := strings.Cut(rel, ":") + r.versions[id] = append(r.versions[id], kiosk.RegistryVersion{Version: v, Framework: fw, Requires: reqs, Module: mod, Package: pkg}) + } + d := kiosk.RegistryPluginDetail{} + d.ID, d.Module, d.Package = id, mod, pkg + d.Repository = "https://example.test/sm-" + pluginName(id) + "-plugin.git" + r.details[id] = d +} + +// installApp writes a minimal application (summer.yaml and a go.mod that +// requires the framework at framework, without a replace) and returns its +// directory. extra are summer.yaml plugins and go.mod requires to add. +func installApp(t *testing.T, framework string, installed map[string]string) string { + t.Helper() + dir := t.TempDir() + yaml := "# The test application.\nmodule: example.test/app\nbinary: app\nplugins:\n" + gomod := "module example.test/app\n\ngo 1.27.0\n\nrequire " + frameworkModule + " " + framework + "\n" + if len(installed) == 0 { + yaml = "module: example.test/app\nbinary: app\n" + } + for _, id := range sortedKeys(kiosk.Requires(installed)) { + mod := "example.test/" + pluginName(id) + yaml += " - id: " + id + "\n module: " + mod + "\n" + gomod += "require " + mod + " " + installed[id] + "\n" + } + write(t, filepath.Join(dir, manifestName), yaml) + write(t, filepath.Join(dir, "go.mod"), gomod) + return dir +} + +func planSteps(p *InstallPlan) string { + var parts []string + for _, s := range p.Steps { + parts = append(parts, s.ID+"@"+s.Version) + } + return strings.Join(parts, ",") +} + +// TestResolveInstall pins the resolution rules of plugin:install against +// an in-memory registry: newest compatible release, pins, framework +// errors, requirement closure and order, installed requirements, cycles, +// offline and disabled registries and refused registry strings. +func TestResolveInstall(t *testing.T) { + ctx := t.Context() + plan := func(app, arg string, reg Registry, mutate ...func(*InstallOptions)) (*InstallPlan, string, error) { + t.Helper() + var out bytes.Buffer + opts := InstallOptions{StartDir: app, Arg: arg, Yes: true, Registry: reg, Out: &out} + for _, m := range mutate { + m(&opts) + } + p, err := PlanInstall(ctx, opts) + return p, out.String(), err + } + + t.Run("newest compatible", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", nil, "v0.3.0:v0.7.0", "v0.10.0:v0.6.0", "v0.2.0:v0.6.0", "v0.1.0:v0.5.0", "v0.4.0-rc.1:v0.5.0") + app := installApp(t, "v0.6.0", nil) + p, out, err := plan(app, "acme.shop", reg) + if err != nil { + t.Fatalf("plan: %v", err) + } + if got := planSteps(p); got != "acme.shop@v0.10.0" { + t.Errorf("steps = %s", got) + } + if p.Framework != "v0.6.0" || p.AppDir != app || p.Steps[0].Mode != ModeModule || p.Steps[0].Module != "example.test/shop" { + t.Errorf("plan = %+v", p) + } + if !strings.Contains(out, "Installing 1 plugin into "+app+" (framework v0.6.0):\n acme.shop@v0.10.0 (module)\n") { + t.Errorf("summary:\n%s", out) + } + }) + + t.Run("pin", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", nil, "v0.3.0:v0.7.0", "v0.2.0:v0.6.0", "v0.1.0:v0.5.0") + app := installApp(t, "v0.6.0", nil) + if p, _, err := plan(app, "acme.shop@v0.1.0", reg); err != nil || planSteps(p) != "acme.shop@v0.1.0" { + t.Errorf("compatible pin: %v, %v", p, err) + } + _, _, err := plan(app, "acme.shop@v0.3.0", reg) + if err == nil || !strings.Contains(err.Error(), "acme.shop v0.3.0 needs framework v0.7.0 or newer; this application builds against v0.6.0") || + !strings.Contains(fullError(err), "go get "+frameworkModule+"@v0.7.0") { + t.Errorf("incompatible pin: %v (%s)", err, fullError(err)) + } + _, _, err = plan(app, "acme.shop@v0.9.0", reg) + if err == nil || !strings.Contains(err.Error(), "no release v0.9.0; available: v0.3.0, v0.2.0, v0.1.0") { + t.Errorf("unknown pin: %v", err) + } + }) + + t.Run("no compatible release names the minimum framework", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", nil, "v0.2.0:v0.9.0", "v0.3.0:v0.8.0") + app := installApp(t, "v0.6.0", nil) + _, _, err := plan(app, "acme.shop", reg) + if err == nil || !strings.Contains(err.Error(), "acme.shop v0.3.0 needs framework v0.8.0 or newer") { + t.Errorf("err = %v, want the newest release's framework named", err) + } + empty := &memRegistry{} + empty.add("acme.shop", nil) + if _, _, err := plan(app, "acme.shop", empty); err == nil || !strings.Contains(err.Error(), "has no release in the registry") { + t.Errorf("no releases: %v", err) + } + }) + + t.Run("requirement closure in dependency order", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.1.0", "acme.payment": "v0.2.0"}, "v0.1.0:v0.6.0") + reg.add("acme.payment", kiosk.Requires{"acme.user": "v0.1.2"}, "v0.3.0:v0.6.0", "v0.2.0:v0.6.0") + reg.add("acme.user", nil, "v0.1.5:v0.7.0", "v0.1.3:v0.6.0", "v0.1.0:v0.5.0") + app := installApp(t, "v0.6.0", nil) + p, out, err := plan(app, "acme.shop", reg) + if err != nil { + t.Fatalf("plan: %v", err) + } + if got := planSteps(p); got != "acme.user@v0.1.3,acme.payment@v0.3.0,acme.shop@v0.1.0" { + t.Errorf("steps = %s", got) + } + if !strings.Contains(out, "Installing 3 plugins") { + t.Errorf("summary:\n%s", out) + } + }) + + t.Run("requirement minimum above the newest compatible release", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.2.0"}, "v0.1.0:v0.6.0") + reg.add("acme.user", nil, "v0.2.0:v0.7.0", "v0.1.9:v0.6.0") + app := installApp(t, "v0.6.0", nil) + _, _, err := plan(app, "acme.shop", reg) + if err == nil || !strings.Contains(err.Error(), "acme.user v0.2.0 needs framework v0.7.0") { + t.Errorf("err = %v", err) + } + reg.add("acme.lonely", kiosk.Requires{"acme.user": "v0.5.0"}, "v0.1.0:v0.6.0") + _, _, err = plan(app, "acme.lonely", reg) + if err == nil || !strings.Contains(err.Error(), "acme.user has no release at or above the required minimum v0.5.0") { + t.Errorf("err = %v", err) + } + }) + + t.Run("plugins of one module share a release", func(t *testing.T) { + reg := &memRegistry{} + reg.addIn("acme.pg", "example.test/pg", "example.test/pg", nil, "v0.2.0:v0.6.0", "v0.1.0:v0.6.0") + reg.addIn("acme.pgsubs", "example.test/pg", "example.test/pg/subs", kiosk.Requires{"acme.pg": ""}, "v0.2.0:v0.6.0", "v0.1.0:v0.6.0") + app := installApp(t, "v0.6.0", nil) + p, _, err := plan(app, "acme.pgsubs@v0.1.0", reg) + if err != nil { + t.Fatalf("plan: %v", err) + } + if got := planSteps(p); got != "acme.pg@v0.1.0,acme.pgsubs@v0.1.0" { + t.Errorf("steps = %s, want both at the pinned module release", got) + } + }) + + t.Run("installed requirements", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.1.2"}, "v0.1.0:v0.6.0") + reg.add("acme.user", nil, "v0.1.3:v0.6.0") + old := installApp(t, "v0.6.0", map[string]string{"acme.user": "v0.1.1"}) + _, _, err := plan(old, "acme.shop", reg) + if err == nil || !strings.Contains(err.Error(), "acme.shop needs acme.user v0.1.2 or newer, but the application has v0.1.1") || + !strings.Contains(fullError(err), "plugin:update acme.user") { + t.Errorf("too old: %v (%s)", err, fullError(err)) + } + fresh := installApp(t, "v0.6.0", map[string]string{"acme.user": "v0.1.2"}) + p, _, err := plan(fresh, "acme.shop", reg) + if err != nil || planSteps(p) != "acme.shop@v0.1.0" { + t.Errorf("installed at the minimum: %v, %v", p, err) + } + _, _, err = plan(fresh, "acme.user", reg) + if err == nil || !strings.Contains(err.Error(), "already in summer.yaml") || !strings.Contains(fullError(err), "plugin:update acme.user") { + t.Errorf("installing an installed plugin: %v", err) + } + }) + + t.Run("cycle", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.a", kiosk.Requires{"acme.b": ""}, "v0.1.0:v0.6.0") + reg.add("acme.b", kiosk.Requires{"acme.c": ""}, "v0.1.0:v0.6.0") + reg.add("acme.c", kiosk.Requires{"acme.a": ""}, "v0.1.0:v0.6.0") + _, _, err := plan(installApp(t, "v0.6.0", nil), "acme.a", reg) + if err == nil || !strings.Contains(err.Error(), "dependency cycle in the registry data: acme.a -> acme.b -> acme.c -> acme.a") { + t.Errorf("err = %v", err) + } + }) + + t.Run("offline and disabled", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", nil, "v0.1.0:v0.6.0") + app := installApp(t, "v0.6.0", nil) + _, _, err := plan(app, "acme.shop", reg, func(o *InstallOptions) { o.Offline = true }) + if err == nil || !strings.Contains(err.Error(), "--offline makes no registry calls") || !strings.Contains(fullError(err), "plugin:install ") { + t.Errorf("offline: %v (%s)", err, fullError(err)) + } + if len(reg.calls) != 0 { + t.Errorf("offline made registry calls: %v", reg.calls) + } + _, _, err = plan(app, "acme.shop", nil) + if err == nil || !strings.Contains(err.Error(), "plugins.registry_url is empty") { + t.Errorf("disabled: %v", err) + } + reg.fail = errors.New("connection refused") + _, _, err = plan(app, "acme.shop", reg) + if err == nil || !strings.Contains(err.Error(), "connection refused") { + t.Errorf("registry down: %v", err) + } + }) + + t.Run("refused registry strings", func(t *testing.T) { + app := installApp(t, "v0.6.0", nil) + cases := map[string]struct { + mod, pkg string + reqs kiosk.Requires + release string + want string + }{ + "module flag": {"-modfile=/tmp/x", "-modfile=/tmp/x", nil, "v0.1.0:v0.6.0", "module"}, + "module shell": {"example.test/x;rm", "example.test/x;rm", nil, "v0.1.0:v0.6.0", "module"}, + "package outside": {"example.test/x", "example.test/y", nil, "v0.1.0:v0.6.0", "not inside module"}, + "required id": {"example.test/x", "example.test/x", kiosk.Requires{"Bad ID": ""}, "v0.1.0:v0.6.0", "refused"}, + "version": {"example.test/x", "example.test/x", nil, "v0.1:v0.6.0", "has no release in the registry"}, + } + for name, c := range cases { + reg := &memRegistry{} + reg.addIn("acme.x", c.mod, c.pkg, c.reqs, c.release) + _, _, err := plan(app, "acme.x", reg) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: err = %v, want %q", name, err, c.want) + } + } + for _, arg := range []string{"", "acme", "Acme.Shop", "acme.shop@latest", "acme.shop@v1", "acme.shop@v1.0.0-rc.1", "-x.y"} { + if _, _, err := plan(app, arg, &memRegistry{}); err == nil { + t.Errorf("arg %q accepted", arg) + } + } + }) + + t.Run("prompt", func(t *testing.T) { + reg := &memRegistry{} + reg.add("acme.shop", nil, "v0.1.0:v0.6.0") + app := installApp(t, "v0.6.0", nil) + before, _ := os.ReadFile(filepath.Join(app, manifestName)) + _, out, err := plan(app, "acme.shop", reg, func(o *InstallOptions) { o.Yes = false }) + if err == nil || !strings.Contains(err.Error(), "pass --yes") || !strings.Contains(out, "acme.shop@v0.1.0 (module)") { + t.Errorf("non-interactive: %v\n%s", err, out) + } + var question string + _, _, err = plan(app, "acme.shop", reg, func(o *InstallOptions) { + o.Yes, o.Interactive = false, true + o.Confirm = func(q string) (bool, error) { question = q; return false, nil } + }) + if !errors.Is(err, ErrCancelled) || question != "Install 1 plugin?" { + t.Errorf("declined: %v, asked %q", err, question) + } + _, _, err = plan(app, "acme.shop", reg, func(o *InstallOptions) { + o.Yes, o.Interactive = false, true + o.Confirm = func(string) (bool, error) { return false, errors.New("tty closed") } + }) + if err == nil || !strings.Contains(err.Error(), "tty closed") { + t.Errorf("prompt error: %v", err) + } + if after, _ := os.ReadFile(filepath.Join(app, manifestName)); !bytes.Equal(before, after) { + t.Error("planning changed summer.yaml") + } + }) + + t.Run("not an application", func(t *testing.T) { + if _, _, err := plan(t.TempDir(), "acme.shop", &memRegistry{}); err == nil { + t.Error("PlanInstall outside an application succeeded") + } + }) + + t.Run("apply refuses an empty or tampered plan", func(t *testing.T) { + if err := ApplyInstall(ctx, nil, nil); err == nil { + t.Error("ApplyInstall(nil) succeeded") + } + bad := &InstallPlan{AppDir: t.TempDir(), Steps: []InstallStep{{ID: "acme.x", Version: "v0.1.0", Module: "-x", Package: "-x", Mode: ModeModule}}} + if err := ApplyInstall(ctx, bad, nil); err == nil || !strings.Contains(err.Error(), "refused") { + t.Errorf("tampered plan: %v", err) + } + mode := &InstallPlan{AppDir: t.TempDir(), Steps: []InstallStep{{ID: "acme.x", Version: "v0.1.0", Module: "example.test/x", Package: "example.test/x", Mode: "zip"}}} + if err := ApplyInstall(ctx, mode, nil); err == nil || !strings.Contains(err.Error(), "unknown install mode") { + t.Errorf("unknown mode: %v", err) + } + }) +} + +func TestAskToProceed(t *testing.T) { + yes := func(string) (bool, error) { return true, nil } + if err := askToProceed(true, false, nil, "q"); err != nil { + t.Errorf("--yes: %v", err) + } + if err := askToProceed(false, false, yes, "q"); err == nil || !strings.Contains(err.Error(), "--yes") { + t.Errorf("non-interactive: %v", err) + } + if err := askToProceed(false, true, nil, "q"); err == nil || !strings.Contains(err.Error(), "--yes") { + t.Errorf("interactive without a prompt: %v", err) + } + if err := askToProceed(false, true, yes, "q"); err != nil { + t.Errorf("accepted: %v", err) + } +} diff --git a/internal/build/manifest.go b/internal/build/manifest.go index ef59fc85..d8d1d409 100644 --- a/internal/build/manifest.go +++ b/internal/build/manifest.go @@ -249,10 +249,11 @@ func insertManifestPlugins(src string, plugins []Plugin) (string, error) { break } } - render := func(dash, keyIndent string) string { + // dash is the indentation before "-", gap the spacing after it. + render := func(dash, gap, keyIndent string) string { var b strings.Builder for _, p := range plugins { - fmt.Fprintf(&b, "%s- id: %s\n%smodule: %s\n", dash, p.ID, keyIndent, p.Module) + fmt.Fprintf(&b, "%s-%sid: %s\n%smodule: %s\n", dash, gap, p.ID, keyIndent, p.Module) } return b.String() } @@ -261,7 +262,7 @@ func insertManifestPlugins(src string, plugins []Plugin) (string, error) { if out != "" && !strings.HasSuffix(out, "\n") { out += "\n" } - return out + "plugins:\n" + render(" ", " "), nil + return out + "plugins:\n" + render(" ", " ", " "), nil } rest := strings.TrimSpace(stripYAMLComment(strings.TrimPrefix(lines[key], "plugins:"))) switch rest { @@ -272,7 +273,7 @@ func insertManifestPlugins(src string, plugins []Plugin) (string, error) { // The block runs until the next line that starts a top-level key. end := key + 1 last := key - dash, keyIndent := "", "" + dash, gap, keyIndent := "", " ", "" for ; end < len(lines); end++ { l := strings.TrimRight(lines[end], "\r\n") trimmed := strings.TrimSpace(l) @@ -287,7 +288,10 @@ func insertManifestPlugins(src string, plugins []Plugin) (string, error) { if dash == "" && strings.HasPrefix(trimmed, "-") { dash = l[:len(l)-len(strings.TrimLeft(l, " \t"))] afterDash := strings.TrimLeft(trimmed[1:], " \t") - keyIndent = strings.Repeat(" ", len(dash)+len(trimmed)-len(afterDash)) + if g := trimmed[1 : len(trimmed)-len(afterDash)]; g != "" && !strings.Contains(g, "\t") { + gap = g + } + keyIndent = strings.Repeat(" ", len(dash)+1+len(gap)) } } if dash == "" && keyIndent == "" { @@ -303,7 +307,7 @@ func insertManifestPlugins(src string, plugins []Plugin) (string, error) { if !strings.HasSuffix(out, "\n") { out += "\n" } - return out + render(dash, keyIndent) + strings.Join(tail, ""), nil + return out + render(dash, gap, keyIndent) + strings.Join(tail, ""), nil } // stripYAMLComment drops a trailing " # comment" from a YAML value. diff --git a/internal/build/manifest_append_test.go b/internal/build/manifest_append_test.go new file mode 100644 index 00000000..34f51d30 --- /dev/null +++ b/internal/build/manifest_append_test.go @@ -0,0 +1,129 @@ +package build + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestAppendManifestPlugins pins the in-place summer.yaml edit of +// plugin:add and plugin:install: comments, blank lines and key order stay, +// new items take the existing indentation, a missing plugins block is +// appended, and an edit that does not parse back leaves the file as it was. +func TestAppendManifestPlugins(t *testing.T) { + add := []Plugin{{ID: "acme.shop", Module: "example.test/sm-shop-plugin"}, {ID: "acme.pgsubs", Module: "example.test/pg/subs"}} + cases := []struct { + name, src, want string + }{ + { + name: "comments, blank lines and later keys stay", + src: "# The application.\nmodule: example.test/app # app module\nbinary: app\n\nplugins:\n" + + " # Core plugins.\n - id: acme.user\n module: example.test/sm-user-plugin # pinned\n\n" + + " - id: acme.blog\n module: example.test/sm-blog-plugin\n\n# Build tags.\ntags:\n - summercms_mcp\n", + want: "# The application.\nmodule: example.test/app # app module\nbinary: app\n\nplugins:\n" + + " # Core plugins.\n - id: acme.user\n module: example.test/sm-user-plugin # pinned\n\n" + + " - id: acme.blog\n module: example.test/sm-blog-plugin\n" + + " - id: acme.shop\n module: example.test/sm-shop-plugin\n - id: acme.pgsubs\n module: example.test/pg/subs\n" + + "\n# Build tags.\ntags:\n - summercms_mcp\n", + }, + { + name: "indentation is matched", + src: "module: example.test/app\nbinary: app\nplugins:\n- id: acme.user\n module: example.test/sm-user-plugin\n", + want: "module: example.test/app\nbinary: app\nplugins:\n- id: acme.user\n module: example.test/sm-user-plugin\n" + + "- id: acme.shop\n module: example.test/sm-shop-plugin\n- id: acme.pgsubs\n module: example.test/pg/subs\n", + }, + { + name: "four-space indentation", + src: "module: example.test/app\nbinary: app\nplugins:\n - id: acme.user\n module: example.test/sm-user-plugin\n", + want: "module: example.test/app\nbinary: app\nplugins:\n - id: acme.user\n module: example.test/sm-user-plugin\n" + + " - id: acme.shop\n module: example.test/sm-shop-plugin\n - id: acme.pgsubs\n module: example.test/pg/subs\n", + }, + { + name: "dash alone on its line", + src: "module: example.test/app\nbinary: app\nplugins:\n -\n id: acme.user\n module: example.test/sm-user-plugin\n", + want: "module: example.test/app\nbinary: app\nplugins:\n -\n id: acme.user\n module: example.test/sm-user-plugin\n" + + " - id: acme.shop\n module: example.test/sm-shop-plugin\n - id: acme.pgsubs\n module: example.test/pg/subs\n", + }, + { + name: "missing plugins block is appended", + src: "# App.\nmodule: example.test/app\nbinary: app", + want: "# App.\nmodule: example.test/app\nbinary: app\nplugins:\n" + + " - id: acme.shop\n module: example.test/sm-shop-plugin\n - id: acme.pgsubs\n module: example.test/pg/subs\n", + }, + { + name: "empty inline list becomes a block", + src: "module: example.test/app\nbinary: app\nplugins: [] # none yet\ntags: []\n", + want: "module: example.test/app\nbinary: app\nplugins:\n" + + " - id: acme.shop\n module: example.test/sm-shop-plugin\n - id: acme.pgsubs\n module: example.test/pg/subs\n" + + "tags: []\n", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), manifestName) + write(t, path, c.src) + if err := appendManifestPlugins(path, add); err != nil { + t.Fatalf("appendManifestPlugins: %v", err) + } + got, _ := os.ReadFile(path) + if string(got) != c.want { + t.Errorf("summer.yaml =\n%s\nwant\n%s", got, c.want) + } + m, err := LoadManifest(path) + if err != nil { + t.Fatal(err) + } + n := len(m.Plugins) + if n < 2 || m.Plugins[n-2] != add[0] || m.Plugins[n-1] != add[1] { + t.Errorf("plugins = %+v", m.Plugins) + } + }) + } + + t.Run("refusals leave the file unchanged", func(t *testing.T) { + refusals := []struct { + name, src string + plugins []Plugin + want string + }{ + {"inline list", "module: example.test/app\nbinary: app\nplugins: [{id: acme.user, module: example.test/u}]\n", add, "inline"}, + {"bad id", "module: example.test/app\nbinary: app\n", []Plugin{{ID: "Acme", Module: "example.test/x"}}, "plugins[0]"}, + {"bad module", "module: example.test/app\nbinary: app\n", []Plugin{{ID: "acme.x", Module: "-x"}}, "module"}, + {"broken file", "module: [\n", add, ""}, + // A second YAML document after the plugins block: the items + // land in it, the first document does not gain them, and the + // original bytes are restored. + {"edit does not parse back", "module: example.test/app\nbinary: app\nplugins:\n- id: acme.user\n module: example.test/sm-user-plugin\n---\nother: 1\n", add, "left unchanged"}, + } + for _, r := range refusals { + path := filepath.Join(t.TempDir(), manifestName) + write(t, path, r.src) + err := appendManifestPlugins(path, r.plugins) + if err == nil || !strings.Contains(err.Error(), r.want) { + t.Errorf("%s: err = %v, want %q", r.name, err, r.want) + } + if got, _ := os.ReadFile(path); string(got) != r.src { + t.Errorf("%s: file changed:\n%s", r.name, got) + } + } + }) + + t.Run("nothing to add", func(t *testing.T) { + path := filepath.Join(t.TempDir(), manifestName) + src := "module: example.test/app\nbinary: app\n" + write(t, path, src) + if err := appendManifestPlugins(path, nil); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(path); string(got) != src { + t.Errorf("file changed:\n%s", got) + } + }) + + t.Run("missing file", func(t *testing.T) { + if err := appendManifestPlugins(filepath.Join(t.TempDir(), manifestName), add); err == nil { + t.Error("appendManifestPlugins on a missing file succeeded") + } + }) +} diff --git a/internal/build/plugindirs.go b/internal/build/plugindirs.go index 9cd687b4..2d0ceefe 100644 --- a/internal/build/plugindirs.go +++ b/internal/build/plugindirs.go @@ -132,7 +132,7 @@ func resolvePluginInfo(ctx context.Context, appDir string, m Manifest) ([]party. if w := readPluginYAML(pkg.Dir, info); w != "" { warnings = append(warnings, fmt.Sprintf("plugin metadata: %s: %s", p.ID, w)) } - pluginVersion(ctx, pkg, info) + pluginVersion(ctx, appDir, pkg, info) } return infos, warnings } @@ -163,8 +163,10 @@ func readPluginYAML(dir string, info *party.Info) string { // pluginVersion sets info.Source and info.Version (D-07): the module // version for a plugin from the module cache, git describe for a plugin -// directory that is its own repository, nothing for an in-app plugin. -func pluginVersion(ctx context.Context, pkg listedPackage, info *party.Info) { +// directory that is its own repository, nothing for an in-app plugin. A +// package of the application's own module is in-app even though the +// application directory is the top level of a repository. +func pluginVersion(ctx context.Context, appDir string, pkg listedPackage, info *party.Info) { mod := pkg.Module switch { case mod == nil: @@ -178,7 +180,10 @@ func pluginVersion(ctx context.Context, pkg listedPackage, info *party.Info) { if root == "" { root = pkg.Dir } - if isOwnRepository(ctx, root) { + if abs, err := filepath.Abs(appDir); err == nil { + appDir = abs + } + if !samePath(root, appDir) && isOwnRepository(ctx, root) { info.Source = party.SourceSubmodule info.Version = gitDescribe(ctx, root) } else { diff --git a/internal/build/plugindirs_test.go b/internal/build/plugindirs_test.go new file mode 100644 index 00000000..d13c7d22 --- /dev/null +++ b/internal/build/plugindirs_test.go @@ -0,0 +1,303 @@ +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") + } + }) +}