- 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
380 lines
15 KiB
Go
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)
|
|
}
|
|
}
|