- --source clones each registry repository (https only) at the chosen tag, adds it as a submodule under plugins/<vendor>/<name>, absorbs its git dir and registers it with the tag as the go.mod require - git URLs (https, ssh, user@host:path) are cloned with the user's git setup and never sent to the registry; the newest tag whose plugin.yaml framework minimum fits is checked out, else the default branch head - go.work replace overrides for every local replace in workspace modules' go.mod files (app target, versioned workspace module, or version pin) - GIT_ALLOW_PROTOCOL=https:ssh, '--' before URLs and paths; non-git apps are refused before cloning; plugin:add keeps require v0.0.0
399 lines
12 KiB
Go
399 lines
12 KiB
Go
package build
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/kiosk"
|
|
)
|
|
|
|
// writeProxyModule adds modPath@version to the file:// GOPROXY tree at
|
|
// proxyDir: @v/list, .info, .mod (files["go.mod"]) and .zip, whose
|
|
// entries are prefixed <module>@<version>/ as the module zip format
|
|
// requires. Module paths in fixtures are lower case, so no escaping is
|
|
// needed.
|
|
func writeProxyModule(t *testing.T, proxyDir, modPath, version string, files map[string]string) {
|
|
t.Helper()
|
|
if strings.ToLower(modPath) != modPath {
|
|
t.Fatalf("fixture module path %q must be lower case", modPath)
|
|
}
|
|
gomod, ok := files["go.mod"]
|
|
if !ok {
|
|
t.Fatalf("fixture %s@%s has no go.mod", modPath, version)
|
|
}
|
|
dir := filepath.Join(proxyDir, filepath.FromSlash(modPath), "@v")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, _ := os.ReadFile(filepath.Join(dir, "list"))
|
|
if !strings.Contains("\n"+string(list), "\n"+version+"\n") {
|
|
list = append(list, []byte(version+"\n")...)
|
|
}
|
|
write(t, filepath.Join(dir, "list"), string(list))
|
|
info, _ := json.Marshal(map[string]string{"Version": version, "Time": "2026-10-01T00:00:00Z"})
|
|
write(t, filepath.Join(dir, version+".info"), string(info))
|
|
write(t, filepath.Join(dir, version+".mod"), gomod)
|
|
|
|
f, err := os.Create(filepath.Join(dir, version+".zip"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zw := zip.NewWriter(f)
|
|
names := make([]string, 0, len(files))
|
|
for name := range files {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
w, err := zw.Create(modPath + "@" + version + "/" + name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := w.Write([]byte(files[name])); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// fixturePluginFiles is a compiling plugin package with the given ID in
|
|
// a module that requires the framework at framework.
|
|
func fixturePluginFiles(modPath, pkg, id, framework string, extraRequires ...string) map[string]string {
|
|
var req strings.Builder
|
|
req.WriteString("require (\n\t" + frameworkModule + " " + framework + "\n")
|
|
for _, r := range extraRequires {
|
|
req.WriteString("\t" + r + "\n")
|
|
}
|
|
req.WriteString(")\n")
|
|
gomod := "module " + modPath + "\n\ngo 1.27.0\n\n" + req.String()
|
|
src := fmt.Sprintf(`package %s
|
|
|
|
import (
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// Plugin is the %s fixture plugin.
|
|
type Plugin struct{}
|
|
|
|
func (p *Plugin) ID() string { return %q }
|
|
func (p *Plugin) Requires() []string { return nil }
|
|
func (p *Plugin) Register(*backpack.App) error { return nil }
|
|
func (p *Plugin) Boot(*backpack.App) error { return nil }
|
|
|
|
func init() { party.Register(&Plugin{}) }
|
|
`, pkg, id, id)
|
|
return map[string]string{"go.mod": gomod, "plugin.go": src}
|
|
}
|
|
|
|
// offlineGoEnv points the go commands of the test at the file:// proxy
|
|
// tree proxyDir, falling back to the developer's module download cache
|
|
// for every other module, with a fresh module cache and no checksum
|
|
// database: no network is used. The fresh cache is made writable again
|
|
// before the temp directory is removed, because summer's go runner drops
|
|
// GOFLAGS (and with it -modcacherw).
|
|
func offlineGoEnv(t *testing.T, proxyDir string) {
|
|
t.Helper()
|
|
out, err := exec.CommandContext(t.Context(), "go", "env", "GOMODCACHE").Output()
|
|
if err != nil {
|
|
t.Fatalf("go env GOMODCACHE: %v", err)
|
|
}
|
|
realCache := strings.TrimSpace(string(out))
|
|
modCache := t.TempDir()
|
|
t.Cleanup(func() { makeWritable(modCache) })
|
|
t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxyDir)+",file://"+filepath.ToSlash(filepath.Join(realCache, "cache", "download")))
|
|
t.Setenv("GOMODCACHE", modCache)
|
|
t.Setenv("GOSUMDB", "off")
|
|
t.Setenv("GOFLAGS", "-modcacherw")
|
|
t.Setenv("GOTOOLCHAIN", "local")
|
|
t.Setenv("GONOSUMDB", "")
|
|
t.Setenv("GOPRIVATE", "")
|
|
t.Setenv("GONOPROXY", "")
|
|
}
|
|
|
|
func makeWritable(root string) {
|
|
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err == nil {
|
|
_ = os.Chmod(path, 0o755)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// fakePlugin is one plugin a fakeRegistry serves.
|
|
type fakePlugin struct {
|
|
Detail kiosk.RegistryPluginDetail
|
|
Versions []kiosk.RegistryVersion
|
|
}
|
|
|
|
// fakeReg is an httptest registry speaking the kiosk v1 wire types.
|
|
type fakeReg struct {
|
|
Client *kiosk.Client
|
|
URL string
|
|
|
|
mu sync.Mutex
|
|
plugins map[string]fakePlugin
|
|
requests []string
|
|
}
|
|
|
|
// fakeRegistry serves plugins through httptest and returns a real
|
|
// kiosk.Client for it, so tests exercise the HTTP client too.
|
|
func fakeRegistry(t *testing.T, plugins map[string]fakePlugin) *fakeReg {
|
|
t.Helper()
|
|
r := &fakeReg{plugins: plugins}
|
|
mux := http.NewServeMux()
|
|
notFound := func(w http.ResponseWriter, id string) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_ = json.NewEncoder(w).Encode(kiosk.ErrorResponse{Error: kiosk.RegistryError{Code: kiosk.RegistryErrorNotFound, Message: id + " not found"}})
|
|
}
|
|
mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins", func(w http.ResponseWriter, req *http.Request) {
|
|
r.record(req)
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var resp kiosk.ListResponse
|
|
for _, p := range r.plugins {
|
|
resp.Data = append(resp.Data, p.Detail.RegistryPlugin)
|
|
}
|
|
resp.Meta.Total = len(resp.Data)
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
})
|
|
mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}", func(w http.ResponseWriter, req *http.Request) {
|
|
r.record(req)
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
id := req.PathValue("id")
|
|
p, ok := r.plugins[id]
|
|
if !ok {
|
|
notFound(w, id)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(kiosk.DetailResponse{Data: p.Detail})
|
|
})
|
|
mux.HandleFunc("GET "+kiosk.RegistryAPIPath+"/plugins/{id}/versions", func(w http.ResponseWriter, req *http.Request) {
|
|
r.record(req)
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
id := req.PathValue("id")
|
|
p, ok := r.plugins[id]
|
|
if !ok {
|
|
notFound(w, id)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(kiosk.VersionsResponse{Data: p.Versions})
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
client, err := kiosk.NewClient(srv.URL, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Client, r.URL = client, srv.URL
|
|
return r
|
|
}
|
|
|
|
func (r *fakeReg) record(req *http.Request) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.requests = append(r.requests, req.URL.Path)
|
|
}
|
|
|
|
// Requests returns the request paths served so far.
|
|
func (r *fakeReg) Requests() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.requests...)
|
|
}
|
|
|
|
// set replaces (or adds) one plugin.
|
|
func (r *fakeReg) set(id string, p fakePlugin) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.plugins[id] = p
|
|
}
|
|
|
|
// regVersion is a registry release of a module plugin.
|
|
func regVersion(version, framework, module, pkg string, requires kiosk.Requires) kiosk.RegistryVersion {
|
|
return kiosk.RegistryVersion{
|
|
Version: version,
|
|
Framework: framework,
|
|
Requires: requires,
|
|
Module: module,
|
|
Package: pkg,
|
|
Commit: strings.Repeat("a", 40),
|
|
TaggedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
}
|
|
|
|
// regPlugin is a registry listing whose versions are given newest first.
|
|
func regPlugin(id, repo string, versions ...kiosk.RegistryVersion) fakePlugin {
|
|
p := fakePlugin{Versions: versions}
|
|
p.Detail.ID = id
|
|
p.Detail.Name = id
|
|
p.Detail.Repository = repo
|
|
if len(versions) > 0 {
|
|
p.Detail.Module = versions[0].Module
|
|
p.Detail.Package = versions[0].Package
|
|
p.Detail.LatestVersion = versions[0].Version
|
|
p.Detail.LatestFramework = versions[0].Framework
|
|
p.Detail.Requires = versions[0].Requires
|
|
}
|
|
return p
|
|
}
|
|
|
|
// helloCopy is a copy of examples/hello in a temp directory whose go.mod
|
|
// replaces the framework with this checkout (absolute path), as
|
|
// TestMakeAndAddPluginOnHelloCopy uses it.
|
|
func helloCopy(t *testing.T) string {
|
|
t.Helper()
|
|
return copyHelloApp(t)
|
|
}
|
|
|
|
// fixtureRelease is one tagged commit of a fixture plugin repository.
|
|
type fixtureRelease struct {
|
|
Tag string
|
|
Framework string
|
|
// Requires maps required plugin IDs to {module, minimum}.
|
|
Requires map[string][2]string
|
|
}
|
|
|
|
// pluginRepoFiles is a plugin repository at one release: go.mod in the
|
|
// development layout of the shared plugin repos (the framework and every
|
|
// required plugin replaced with sibling paths that do not exist in an
|
|
// application), plugin.go and the generated plugin.yaml.
|
|
func pluginRepoFiles(t *testing.T, id, module string, rel fixtureRelease) map[string]string {
|
|
t.Helper()
|
|
var gomod strings.Builder
|
|
gomod.WriteString("module " + module + "\n\ngo 1.27.0\n\nrequire (\n\t" + frameworkModule + " " + rel.Framework + "\n")
|
|
reqIDs := make([]string, 0, len(rel.Requires))
|
|
for rid := range rel.Requires {
|
|
reqIDs = append(reqIDs, rid)
|
|
}
|
|
sort.Strings(reqIDs)
|
|
for _, rid := range reqIDs {
|
|
gomod.WriteString("\t" + rel.Requires[rid][0] + " " + rel.Requires[rid][1] + "\n")
|
|
}
|
|
gomod.WriteString(")\n\nreplace " + frameworkModule + " => ../summercms.go\n")
|
|
var imports, reqList strings.Builder
|
|
requires := kiosk.Requires{}
|
|
for _, rid := range reqIDs {
|
|
gomod.WriteString("\nreplace " + rel.Requires[rid][0] + " => ../sm-" + pluginName(rid) + "-plugin\n")
|
|
imports.WriteString("\t_ " + fmt.Sprintf("%q", rel.Requires[rid][0]) + "\n")
|
|
reqList.WriteString(fmt.Sprintf("%q, ", rid))
|
|
requires[rid] = rel.Requires[rid][1]
|
|
}
|
|
pkg := pluginName(id)
|
|
src := fmt.Sprintf(`package %s
|
|
|
|
import (
|
|
%s
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// Plugin is the %s fixture plugin.
|
|
type Plugin struct{}
|
|
|
|
func (p *Plugin) ID() string { return %q }
|
|
func (p *Plugin) Requires() []string { return []string{%s} }
|
|
func (p *Plugin) Register(*backpack.App) error { return nil }
|
|
func (p *Plugin) Boot(*backpack.App) error { return nil }
|
|
|
|
func init() { party.Register(&Plugin{}) }
|
|
`, pkg, imports.String(), id, id, reqList.String())
|
|
m := kiosk.Manifest{
|
|
Schema: kiosk.SchemaVersion,
|
|
ID: id,
|
|
Module: module,
|
|
Package: module,
|
|
Name: "Fixture " + pkg,
|
|
Description: "A fixture plugin for install tests.",
|
|
Framework: rel.Framework,
|
|
}
|
|
if len(requires) > 0 {
|
|
m.Requires = requires
|
|
}
|
|
yml, err := kiosk.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return map[string]string{"go.mod": gomod.String(), "plugin.go": src, kiosk.FileName: string(yml)}
|
|
}
|
|
|
|
// pluginRepo creates a git repository with one tagged commit per release
|
|
// and returns its file:// URL. head, when set, adds an untagged commit on
|
|
// top with that framework minimum.
|
|
func pluginRepo(t *testing.T, id, module string, releases []fixtureRelease, head *fixtureRelease) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
runTestGit(t, dir, "init", "--quiet")
|
|
commit := func(rel fixtureRelease, tag bool) {
|
|
for name, body := range pluginRepoFiles(t, id, module, rel) {
|
|
write(t, filepath.Join(dir, name), body)
|
|
}
|
|
write(t, filepath.Join(dir, "CHANGES"), "release "+rel.Tag+"\n")
|
|
runTestGit(t, dir, "add", "-A")
|
|
runTestGit(t, dir, "commit", "--quiet", "-m", "release "+rel.Tag)
|
|
if tag {
|
|
runTestGit(t, dir, "tag", rel.Tag)
|
|
}
|
|
}
|
|
for _, rel := range releases {
|
|
commit(rel, true)
|
|
}
|
|
if head != nil {
|
|
commit(*head, false)
|
|
}
|
|
return "file://" + filepath.ToSlash(dir)
|
|
}
|
|
|
|
// gitTestEnv isolates git from the developer's config, lets the CLI git
|
|
// runner use file:// fixture repositories and keeps the go commands off
|
|
// the network.
|
|
func gitTestEnv(t *testing.T) {
|
|
t.Helper()
|
|
isolateGit(t)
|
|
old := cliGitProtocols
|
|
cliGitProtocols = "https:ssh:file"
|
|
t.Cleanup(func() { cliGitProtocols = old })
|
|
t.Setenv("GOPROXY", "off")
|
|
t.Setenv("GOFLAGS", "")
|
|
t.Setenv("GOTOOLCHAIN", "local")
|
|
}
|
|
|
|
// gitApp is a helloCopy committed to a fresh git repository.
|
|
func gitApp(t *testing.T) string {
|
|
t.Helper()
|
|
app := helloCopy(t)
|
|
runTestGit(t, app, "init", "--quiet")
|
|
runTestGit(t, app, "add", "-A")
|
|
runTestGit(t, app, "commit", "--quiet", "-m", "app")
|
|
return app
|
|
}
|
|
|
|
// installTemps lists leftover temporary checkouts under plugins/.
|
|
func installTemps(t *testing.T, app string) []string {
|
|
t.Helper()
|
|
matches, err := filepath.Glob(filepath.Join(app, "plugins", installTemp+"*"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return matches
|
|
}
|