Files
summercms/internal/build/install_test.go
Jakub Zych 9b72439533 fix(34): CR-02 install plugins of an installed module at its version
plugin:install pinned only modules chosen in the same plan, so a second
plugin package in a module the application already required was
installed at the newest release, moving the plugin already there, and
with --source the repository was cloned a second time. PlanInstall now
seeds every module of summer.yaml: a plugin in one installs at the
installed version with no go get or clone (only its summer.yaml entry),
and is refused with a plugin:update hint when that version has no
release of it or is not a release. A git URL whose repository is already
a submodule, or whose module the application has, is refused.
2026-10-29 22:01:30 +01:00

512 lines
20 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)
}
}
// TestUpdatePseudoVersion pins CR-01: a module required at a
// pseudo-version (a commit after a release, as go get M@main leaves it) is
// never moved to the release it builds on or an older one, and one with no
// release before its commit is not moved at all.
func TestUpdatePseudoVersion(t *testing.T) {
const after040 = "v0.4.1-0.20261001120000-abcdef123456"
plan := func(t *testing.T, installed string, releases ...string) (string, error) {
t.Helper()
reg := &memRegistry{}
reg.add("acme.blog", nil, releases...)
app := installApp(t, "v0.6.0", map[string]string{"acme.blog": installed})
var out bytes.Buffer
err := Update(t.Context(), UpdateOptions{
StartDir: app, ID: "acme.blog", Registry: reg, Out: &out, Interactive: true,
Confirm: func(string) (bool, error) { return false, nil },
})
return out.String(), err
}
t.Run("only the base release and older", func(t *testing.T) {
out, err := plan(t, after040, "v0.4.0:v0.6.0", "v0.3.0:v0.6.0")
if err != nil {
t.Fatalf("update: %v\n%s", err, out)
}
if !strings.Contains(out, "Nothing to update.") || !strings.Contains(out, "up to date at "+after040) || strings.Contains(out, "-> v0.4.0") {
t.Errorf("a commit after v0.4.0 is moved back:\n%s", out)
}
})
t.Run("a newer release", func(t *testing.T) {
out, err := plan(t, after040, "v0.4.1:v0.6.0", "v0.4.0:v0.6.0")
if !errors.Is(err, ErrCancelled) {
t.Fatalf("update: %v\n%s", err, out)
}
if !strings.Contains(out, "acme.blog "+after040+" -> v0.4.1 (module)") {
t.Errorf("plan:\n%s", out)
}
})
t.Run("no release before the commit", func(t *testing.T) {
out, err := plan(t, "v0.0.0-20261001120000-abcdef123456", "v0.1.0:v0.6.0")
if err != nil {
t.Fatalf("update: %v\n%s", err, out)
}
if !strings.Contains(out, "Nothing to update.") || !strings.Contains(out, "plugin:update does not guess") || !strings.Contains(out, "go get example.test/blog@<tag>") {
t.Errorf("an unplaceable commit is moved:\n%s", out)
}
})
t.Run("a requirement at a pseudo-version", func(t *testing.T) {
reg := &memRegistry{}
reg.add("acme.shop", kiosk.Requires{"acme.user": "v0.1.2"}, "v0.1.0:v0.6.0")
app := installApp(t, "v0.6.0", map[string]string{"acme.user": "v0.1.3-0.20261001120000-abcdef123456"})
var out bytes.Buffer
p, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: "acme.shop", Yes: true, Registry: reg, Out: &out})
if err != nil || planSteps(p) != "acme.shop@v0.1.0" || !strings.Contains(out.String(), "cannot be read") {
t.Errorf("plan: %v, %v\n%s", p, err, out.String())
}
})
}
// TestInstallSharedModule pins CR-02: a plugin in a module the
// application already has installs at the version the application has,
// fetches nothing, and is refused when that version has no release of it.
func TestInstallSharedModule(t *testing.T) {
reg := &memRegistry{}
reg.addIn("acme.pg", "example.test/pg", "example.test/pg", nil, "v0.3.0:v0.6.0", "v0.2.0:v0.6.0")
reg.addIn("acme.pgsubs", "example.test/pg", "example.test/pg/subs", nil, "v0.3.0:v0.6.0", "v0.2.0:v0.6.0")
reg.addIn("acme.pgnew", "example.test/pg", "example.test/pg/fresh", nil, "v0.3.0:v0.6.0")
reg.addIn("acme.shop", "example.test/shop", "example.test/shop", kiosk.Requires{"acme.pgsubs": ""}, "v0.1.0:v0.6.0")
plan := func(t *testing.T, app, arg string) (*InstallPlan, string, error) {
t.Helper()
var out bytes.Buffer
p, err := PlanInstall(t.Context(), InstallOptions{StartDir: app, Arg: arg, Yes: true, Registry: reg, Out: &out})
return p, out.String(), err
}
t.Run("pinned to the installed module version", func(t *testing.T) {
app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.0"})
p, out, err := plan(t, app, "acme.pgsubs")
if err != nil {
t.Fatalf("plan: %v\n%s", err, out)
}
if got := planSteps(p); got != "acme.pgsubs@v0.2.0" || !p.Steps[0].existing {
t.Fatalf("steps = %s %+v, want acme.pgsubs at the installed v0.2.0", got, p.Steps)
}
if !strings.Contains(out, "acme.pgsubs@v0.2.0 (module, module already in the application)") {
t.Errorf("plan output:\n%s", out)
}
gomod, _ := os.ReadFile(filepath.Join(app, "go.mod"))
// ApplyInstall runs no go get for it: only summer.yaml changes.
if err := ApplyInstall(t.Context(), p, nil); err != nil {
t.Fatalf("apply: %v", err)
}
after, _ := os.ReadFile(filepath.Join(app, "go.mod"))
if !bytes.Equal(gomod, after) {
t.Errorf("go.mod changed:\n%s", after)
}
yaml, _ := os.ReadFile(filepath.Join(app, manifestName))
if !strings.Contains(string(yaml), "id: acme.pgsubs\n module: example.test/pg/subs") {
t.Errorf("summer.yaml:\n%s", yaml)
}
})
t.Run("a requirement in the installed module", func(t *testing.T) {
app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.0"})
p, out, err := plan(t, app, "acme.shop")
if err != nil || planSteps(p) != "acme.pgsubs@v0.2.0,acme.shop@v0.1.0" {
t.Fatalf("plan: %v %v\n%s", p, err, out)
}
})
t.Run("refused when the installed version has no release of it", func(t *testing.T) {
app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.0"})
for _, arg := range []string{"acme.pgnew", "acme.pgsubs@v0.3.0"} {
_, out, err := plan(t, app, arg)
if err == nil || !strings.Contains(err.Error(), "lives in example.test/pg, which the application already has at v0.2.0 (for acme.pg)") ||
!strings.Contains(fullError(err), "plugin:update acme.pg") {
t.Errorf("%s: %v\n%s", arg, fullError(err), out)
}
}
})
t.Run("refused at a pseudo-version", func(t *testing.T) {
app := installApp(t, "v0.6.0", map[string]string{"acme.pg": "v0.2.1-0.20261001120000-abcdef123456"})
_, out, err := plan(t, app, "acme.pgsubs")
if err == nil || !strings.Contains(err.Error(), "not a release plugin:install can pin") {
t.Errorf("err = %v\n%s", fullError(err), out)
}
})
}