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
This commit is contained in:
Jakub Zych
2026-10-29 21:22:47 +01:00
parent 9109e5b139
commit 7de774b0b3
5 changed files with 830 additions and 10 deletions

View File

@@ -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/<name>, 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 <git-url>") {
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)
}
}

View File

@@ -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.

View File

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

View File

@@ -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 {

View File

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