Files
summercms/internal/build/install_test.go
Jakub Zych 7de774b0b3 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
2026-10-29 21:22:47 +01:00

380 lines
15 KiB
Go

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