Files
summercms/modules/kiosk/manifest_smoke_test.go
Jakub Zych 4916ee3d43 feat(kiosk): registry v1 wire types and version rules
- RegistryAPIPath, RegistryPlugin, RegistryPluginDetail, RegistryVersion, envelopes, errors, ingest types
- ValidVersion, CompareVersions, BaseVersion, Satisfies, NewestCompatible
- docs: Registry API v1 reference in plugins/marketplace.md
2026-10-29 17:05:41 +01:00

498 lines
19 KiB
Go

package kiosk
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// copyTestdata copies testdata/<names...> into a fresh temp dir, keeping
// the relative layout, and returns the temp dir.
func copyTestdata(t *testing.T, names ...string) string {
t.Helper()
dst := t.TempDir()
for _, name := range names {
src := filepath.Join("testdata", name)
err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel("testdata", path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
if err != nil {
t.Fatalf("copy %s: %v", src, err)
}
}
return dst
}
// TestManifestSmoke drives plugin.yaml generation end to end on the
// fixture plugins in testdata.
func TestManifestSmoke(t *testing.T) {
ctx := context.Background()
t.Run("literal", func(t *testing.T) {
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
path, warnings, err := Write(ctx, dir, DeriveOptions{})
if err != nil {
t.Fatalf("Write: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings = %v", warnings)
}
if path != filepath.Join(dir, FileName) {
t.Errorf("path = %s", path)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(raw), Header+"\n") {
t.Errorf("plugin.yaml does not start with the header:\n%s", raw)
}
m, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := Manifest{
Schema: SchemaVersion,
ID: "acme.blog",
Module: "example.test/sm-acme-plugin",
Package: "example.test/sm-acme-plugin",
Name: "Blog",
Description: "Posts and categories for an acme application, with a feed.",
Framework: "v0.5.3",
}
if m.Schema != want.Schema || m.ID != want.ID || m.Module != want.Module || m.Package != want.Package ||
m.Name != want.Name || m.Description != want.Description || m.Framework != want.Framework ||
len(m.Requires) != 0 || m.Overrides != nil {
t.Errorf("manifest = %+v, want %+v", m, want)
}
wantOrder := []string{"schema:", "id:", "module:", "package:", "name:", "description:", "framework:"}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")[1:]
if len(lines) != len(wantOrder) {
t.Fatalf("lines = %q", lines)
}
for i, prefix := range wantOrder {
if !strings.HasPrefix(lines[i], prefix) {
t.Errorf("line %d = %q, want prefix %q", i+2, lines[i], prefix)
}
}
if err := Check(ctx, dir); err != nil {
t.Fatalf("Check on a fresh plugin.yaml: %v", err)
}
readme := filepath.Join(dir, "README.md")
src, _ := os.ReadFile(readme)
edited := strings.Replace(string(src), "with a feed.", "with an Atom feed.", 1)
if err := os.WriteFile(readme, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
var stale *StaleError
if err := Check(ctx, dir); !errors.As(err, &stale) || stale.Missing {
t.Fatalf("Check after a README edit = %v, want a stale *StaleError", err)
}
if !strings.Contains(stale.Error(), path) || !strings.Contains(stale.Error(), "line ") {
t.Errorf("StaleError text = %q", stale.Error())
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := Check(ctx, dir); !errors.As(err, &stale) || !stale.Missing {
t.Fatalf("Check without plugin.yaml = %v, want Missing", err)
}
if len(stale.Want) == 0 {
t.Error("a missing StaleError carries no Want bytes")
}
})
t.Run("consts", func(t *testing.T) {
base := copyTestdata(t, "framework", "other", "consts")
dir := filepath.Join(base, "consts")
id, err := ReadIdentity(ctx, dir)
if err != nil {
t.Fatalf("ReadIdentity: %v", err)
}
if id.ID != "acme.consts" || strings.Join(id.Requires, ",") != "acme.translate,acme.other" {
t.Fatalf("identity = %+v", id)
}
m, warnings, err := Derive(ctx, dir, DeriveOptions{})
if err != nil {
t.Fatalf("Derive: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings = %v", warnings)
}
if m.Requires["acme.other"] != "v0.2.0" {
t.Errorf("acme.other minimum = %q, want the go.mod require v0.2.0", m.Requires["acme.other"])
}
if v, ok := m.Requires["acme.translate"]; !ok || v != "" {
t.Errorf("acme.translate minimum = %q (present %v), want empty", v, ok)
}
if m.Package != "example.test/sm-consts-plugin" || m.Framework != "v0.5.3" {
t.Errorf("manifest = %+v", m)
}
})
t.Run("nested", func(t *testing.T) {
base := copyTestdata(t, "framework", "other", "consts")
m, _, err := Derive(ctx, filepath.Join(base, "consts", "nested"), DeriveOptions{})
if err != nil {
t.Fatalf("Derive: %v", err)
}
if m.ID != "acme.constsnested" || m.Module != "example.test/sm-consts-plugin" || m.Package != "example.test/sm-consts-plugin/nested" {
t.Errorf("manifest = %+v", m)
}
if v, ok := m.Requires["acme.consts"]; !ok || v != "" || len(m.Requires) != 1 {
t.Errorf("requires = %v, want acme.consts with the empty minimum", m.Requires)
}
})
t.Run("errors", func(t *testing.T) {
cases := []struct {
name, src, want string
}{
{"id call", `func (*Plugin) ID() string { return id() }
func id() string { return "acme.x" }
func (*Plugin) Requires() []string { return nil }`, "ID()"},
{"id var", `var pluginID = "acme.x"
func (*Plugin) ID() string { return pluginID }
func (*Plugin) Requires() []string { return nil }`, "ID()"},
{"id concat", `func suffix() string { return "x" }
func (*Plugin) ID() string { return "acme." + suffix() }
func (*Plugin) Requires() []string { return nil }`, "ID()"},
{"requires var", `var deps = []string{"acme.y"}
func (*Plugin) ID() string { return "acme.x" }
func (*Plugin) Requires() []string { return deps }`, "Requires()"},
{"requires call", `func (*Plugin) ID() string { return "acme.x" }
func (*Plugin) Requires() []string { return append([]string(nil), "acme.y") }`, "Requires()"},
{"requires element", `var dep = "acme.y"
func (*Plugin) ID() string { return "acme.x" }
func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()"},
}
for _, tc := range cases {
dir := t.TempDir()
src := "package p\n\ntype Plugin struct{}\n\n" + tc.src + "\n"
if err := os.WriteFile(filepath.Join(dir, "plugin.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.test/p\n\ngo 1.27.0\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := ReadIdentity(ctx, dir)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: err = %v, want one naming %s", tc.name, err, tc.want)
}
}
// A plugin requirement at v0.0.0 is refused; a pseudo-version warns.
base := copyTestdata(t, "framework", "other", "consts")
dir := filepath.Join(base, "consts")
gomod := filepath.Join(dir, "go.mod")
orig, _ := os.ReadFile(gomod)
setOther := func(v string) {
t.Helper()
data := strings.Replace(string(orig), "example.test/sm-other-plugin v0.2.0", "example.test/sm-other-plugin "+v, 1)
if err := os.WriteFile(gomod, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}
setOther("v0.0.0")
if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a released version") {
t.Errorf("v0.0.0 requirement: err = %v", err)
}
pseudo := "v0.2.1-0.20261029101500-abcdefabcdef"
setOther(pseudo)
m, warnings, err := Derive(ctx, dir, DeriveOptions{})
if err != nil {
t.Fatalf("pseudo-version requirement: %v", err)
}
if m.Requires["acme.other"] != pseudo || len(warnings) != 1 || !strings.Contains(warnings[0], "pseudo-version") {
t.Errorf("pseudo-version: requires = %v, warnings = %v", m.Requires, warnings)
}
// The framework require is mandatory and must be released.
lit := filepath.Join(copyTestdata(t, "literal"), "literal")
litMod := filepath.Join(lit, "go.mod")
if err := os.WriteFile(litMod, []byte("module example.test/sm-acme-plugin\n\ngo 1.27.0\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, _, err := Derive(ctx, lit, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), FrameworkModule) {
t.Errorf("missing framework require: err = %v", err)
}
if err := os.WriteFile(litMod, []byte("module example.test/sm-acme-plugin\n\ngo 1.27.0\n\nrequire "+FrameworkModule+" v0.0.0\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, _, err := Derive(ctx, lit, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a released version") {
t.Errorf("framework v0.0.0: err = %v", err)
}
})
t.Run("overrides", func(t *testing.T) {
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
readme := filepath.Join(dir, "README.md")
src, _ := os.ReadFile(readme)
if err := os.WriteFile(readme, []byte(strings.Replace(string(src), "# Blog", "# acme.blog", 1)), 0o644); err != nil {
t.Fatal(err)
}
if _, _, err := Write(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "--name") {
t.Fatalf("H1 equal to the ID: err = %v, want one naming --name", err)
}
name, desc := "Acme Blog", "A blog."
if _, _, err := Write(ctx, dir, DeriveOptions{Name: &name, Description: &desc}); err != nil {
t.Fatalf("Write with overrides: %v", err)
}
if _, _, err := Write(ctx, dir, DeriveOptions{}); err != nil {
t.Fatalf("regeneration without flags: %v", err)
}
raw, _ := os.ReadFile(filepath.Join(dir, FileName))
m, err := Parse(raw)
if err != nil {
t.Fatal(err)
}
if m.Name != name || m.Description != desc || m.Overrides == nil || m.Overrides.Name != name || m.Overrides.Description != desc {
t.Fatalf("after regeneration = %+v (overrides %+v)", m, m.Overrides)
}
if err := Check(ctx, dir); err != nil {
t.Fatalf("Check keeps the overrides: %v", err)
}
empty := ""
if _, _, err := Write(ctx, dir, DeriveOptions{Description: &empty}); err != nil {
t.Fatalf("clearing the description: %v", err)
}
raw, _ = os.ReadFile(filepath.Join(dir, FileName))
if m, _ = Parse(raw); m.Description != "Posts and categories for an acme application, with a feed." || m.Overrides.Description != "" || m.Overrides.Name != name {
t.Fatalf("after clearing the description = %+v (overrides %+v)", m, m.Overrides)
}
if _, _, err := Write(ctx, dir, DeriveOptions{Name: &empty}); err == nil || !strings.Contains(err.Error(), "--name") {
t.Fatalf("clearing the name with H1 == ID: err = %v, want one naming --name", err)
}
})
t.Run("checkt", func(t *testing.T) {
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
if _, _, err := Write(ctx, dir, DeriveOptions{}); err != nil {
t.Fatal(err)
}
var fresh fakeTB
CheckT(&fresh, dir)
if len(fresh.fatals) != 0 {
t.Fatalf("CheckT on a fresh manifest failed: %v", fresh.fatals)
}
readme := filepath.Join(dir, "README.md")
src, _ := os.ReadFile(readme)
if err := os.WriteFile(readme, []byte(strings.Replace(string(src), "# Blog", "# Weblog", 1)), 0o644); err != nil {
t.Fatal(err)
}
var stale fakeTB
CheckT(&stale, dir)
if len(stale.fatals) != 1 || !strings.Contains(stale.fatals[0], filepath.Join(dir, FileName)) || !strings.Contains(stale.fatals[0], "summer plugin:manifest") {
t.Fatalf("CheckT on a stale manifest: %q", stale.fatals)
}
if stale.helpers == 0 {
t.Error("CheckT did not call Helper")
}
})
t.Run("validate", func(t *testing.T) {
good := Manifest{Schema: 1, ID: "acme.blog", Module: "example.test/m", Package: "example.test/m/sub",
Name: "Blog", Description: "A blog.", Framework: "v0.6.0", Requires: Requires{"acme.x": "", "acme.y": "v0.1.0"}}
if err := Validate(good); err != nil {
t.Fatalf("Validate(good) = %v", err)
}
bad := map[string]func(*Manifest){
"id": func(m *Manifest) { m.ID = "Acme.Blog" },
"module dash": func(m *Manifest) { m.Module = "-example.test/m"; m.Package = m.Module },
"package outside": func(m *Manifest) { m.Package = "example.test/other" },
"framework": func(m *Manifest) { m.Framework = "0.6.0" },
"framework rc": func(m *Manifest) { m.Framework = "v0.6.0-rc1" },
"schema": func(m *Manifest) { m.Schema = SchemaVersion + 1 },
"schema zero": func(m *Manifest) { m.Schema = 0 },
"required id": func(m *Manifest) { m.Requires = Requires{"bad": ""} },
"minimum": func(m *Manifest) { m.Requires = Requires{"acme.x": "latest"} },
"name": func(m *Manifest) { m.Name = " " },
}
for name, mutate := range bad {
m := good
m.Requires = Requires{}
mutate(&m)
if err := Validate(m); err == nil {
t.Errorf("Validate accepted a bad %s: %+v", name, m)
}
}
raw, err := Marshal(good)
if err != nil {
t.Fatal(err)
}
newer := strings.Replace(string(raw), "schema: 1", "schema: 2", 1)
if _, err := Parse([]byte(newer)); err == nil || !strings.Contains(err.Error(), "update summer") {
t.Errorf("Parse of a newer schema: err = %v", err)
}
})
t.Run("registry", func(t *testing.T) {
at := time.Date(2026, 10, 29, 10, 0, 0, 0, time.UTC)
plugin := RegistryPlugin{
ID: "acme.blog", Name: "Blog", Description: "A blog.", Module: "example.test/sm-blog-plugin",
Package: "example.test/sm-blog-plugin", Repository: "https://example.test/sm-blog-plugin.git",
Category: "content", Featured: true, IconURL: "https://example.test/icon.png",
LatestVersion: "v0.2.5", LatestFramework: "v0.6.0", UpdatedAt: at,
}
wantPlugin := `{"id":"acme.blog","name":"Blog","description":"A blog.","module":"example.test/sm-blog-plugin",` +
`"package":"example.test/sm-blog-plugin","repository":"https://example.test/sm-blog-plugin.git",` +
`"category":"content","featured":true,"icon_url":"https://example.test/icon.png",` +
`"latest_version":"v0.2.5","latest_framework":"v0.6.0","updated_at":"2026-10-29T10:00:00Z"}`
assertJSON(t, ListResponse{Data: []RegistryPlugin{plugin}, Meta: ListMeta{Total: 1}},
`{"data":[`+wantPlugin+`],"meta":{"total":1}}`)
detail := RegistryPluginDetail{
RegistryPlugin: plugin, ReadmeMarkdown: "# Blog", ReadmeHTML: "<h1>Blog</h1>",
Screenshots: []RegistryScreenshot{{URL: "https://example.test/1.png", Caption: "List"}},
Requires: Requires{"acme.translate": "v0.1.0"},
}
assertJSON(t, DetailResponse{Data: detail}, `{"data":`+wantPlugin[:len(wantPlugin)-1]+
`,"readme_markdown":"# Blog","readme_html":"\u003ch1\u003eBlog\u003c/h1\u003e",`+
`"screenshots":[{"url":"https://example.test/1.png","caption":"List"}],"requires":{"acme.translate":"v0.1.0"}}}`)
version := RegistryVersion{Version: "v0.2.5", Framework: "v0.6.0", Requires: Requires{"acme.translate": "v0.1.0"},
Module: "example.test/sm-blog-plugin", Package: "example.test/sm-blog-plugin", Commit: "abc123", TaggedAt: at}
assertJSON(t, VersionsResponse{Data: []RegistryVersion{version}},
`{"data":[{"version":"v0.2.5","framework":"v0.6.0","requires":{"acme.translate":"v0.1.0"},`+
`"module":"example.test/sm-blog-plugin","package":"example.test/sm-blog-plugin","commit":"abc123","tagged_at":"2026-10-29T10:00:00Z"}]}`)
assertJSON(t, RegistryVersion{Version: "v0.1.0", TaggedAt: at},
`{"version":"v0.1.0","framework":"","requires":{},"module":"","package":"","commit":"","tagged_at":"2026-10-29T10:00:00Z"}`)
assertJSON(t, ErrorResponse{Error: RegistryError{Code: RegistryErrorNotFound, Message: "no plugin acme.x"}},
`{"error":{"code":"not_found","message":"no plugin acme.x"}}`)
assertJSON(t, IngestRequest{Repository: "https://example.test/sm-blog-plugin.git"},
`{"repository":"https://example.test/sm-blog-plugin.git"}`)
assertJSON(t, IngestResponse{Data: IngestResult{Repository: "https://example.test/sm-blog-plugin.git", Queued: true}},
`{"data":{"repository":"https://example.test/sm-blog-plugin.git","queued":true}}`)
if RegistryAPIPath != "/api/registry/v1" || RegistryErrorInvalidQuery != "invalid_query" {
t.Errorf("RegistryAPIPath = %q, RegistryErrorInvalidQuery = %q", RegistryAPIPath, RegistryErrorInvalidQuery)
}
// Clients ignore fields a later v1 server adds.
var got DetailResponse
extra := `{"data":{"id":"acme.blog","latest_version":"v0.2.5","downloads":12,"requires":{"acme.x":""},"new":{"a":[1]}},"links":{}}`
if err := json.Unmarshal([]byte(extra), &got); err != nil {
t.Fatalf("unknown fields: %v", err)
}
if got.Data.ID != "acme.blog" || got.Data.LatestVersion != "v0.2.5" || len(got.Data.Requires) != 1 {
t.Errorf("decoded = %+v", got.Data)
}
})
t.Run("versions", func(t *testing.T) {
for _, v := range []string{"v0.6.0", "v10.2.33", "v0.0.0", "v1.0.0"} {
if !ValidVersion(v) {
t.Errorf("ValidVersion(%q) = false", v)
}
}
for _, v := range []string{"0.6.0", "v0.6", "v0.6.0-rc1", "v0.6.0+meta", "-v0.6.0", "", "v01.2.3", "v0.3.1-4-gabc1234"} {
if ValidVersion(v) {
t.Errorf("ValidVersion(%q) = true", v)
}
}
cmp := []struct {
a, b string
want int
}{
{"v0.10.0", "v0.9.9", 1}, {"v0.9.9", "v0.10.0", -1}, {"v1.2.3", "v1.2.3", 0},
{"v2.0.0", "v1.99.99", 1}, {"v0.5.3", "v0.6.0", -1}, {"v0.0.10", "v0.0.9", 1},
}
for _, c := range cmp {
if got := CompareVersions(c.a, c.b); got != c.want {
t.Errorf("CompareVersions(%s, %s) = %d, want %d", c.a, c.b, got, c.want)
}
}
for in, want := range map[string]string{
"v0.3.1-4-gabc1234": "v0.3.1", "v0.3.1": "v0.3.1", "abc1234": "", "": "",
"v0.3.1-rc1": "", "v0.3": "",
} {
if got := BaseVersion(in); got != want {
t.Errorf("BaseVersion(%q) = %q, want %q", in, got, want)
}
}
pseudo := "v0.5.4-0.20261029101500-abcdefabcdef"
sat := []struct {
have, min string
want bool
}{
{"v0.6.0", "", true}, {"", "", true}, {"v0.6.0", "v0.6.0", true}, {"v0.7.1", "v0.6.0", true},
{"v0.5.3", "v0.6.0", false}, {"", "v0.6.0", false}, {"v0.3.1-4-gabc1234", "v0.3.0", false},
{"v0.5.4", pseudo, true}, {"v0.6.0", pseudo, true}, {"v0.5.3", pseudo, false},
{"v0.6.0", "latest", false},
}
for _, c := range sat {
if got := Satisfies(c.have, c.min); got != c.want {
t.Errorf("Satisfies(%q, %q) = %v, want %v", c.have, c.min, got, c.want)
}
}
versions := []RegistryVersion{
{Version: "v0.3.0", Framework: "v0.7.0"},
{Version: "v0.2.5", Framework: "v0.6.0"},
{Version: "v0.10.0", Framework: "v0.8.0"},
{Version: "v0.2.4", Framework: "v0.5.0"},
{Version: "v0.2.6-rc1", Framework: "v0.5.0"},
}
if got, ok := NewestCompatible(versions, "v0.6.2"); !ok || got.Version != "v0.2.5" {
t.Errorf("NewestCompatible(v0.6.2) = %+v, %v", got, ok)
}
if got, ok := NewestCompatible(versions, "v0.9.0"); !ok || got.Version != "v0.10.0" {
t.Errorf("NewestCompatible(v0.9.0) = %+v, %v", got, ok)
}
if _, ok := NewestCompatible(versions, "v0.4.0"); ok {
t.Error("NewestCompatible(v0.4.0) found a version")
}
if _, ok := NewestCompatible(nil, "v1.0.0"); ok {
t.Error("NewestCompatible(nil) found a version")
}
})
}
// assertJSON marshals v and compares it with want byte for byte.
func assertJSON(t *testing.T, v any, want string) {
t.Helper()
got, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal %T: %v", v, err)
}
if string(got) != want {
t.Errorf("%T JSON\n got %s\nwant %s", v, got, want)
}
}
// fakeTB records CheckT's calls.
type fakeTB struct {
helpers int
fatals []string
}
func (f *fakeTB) Helper() { f.helpers++ }
func (f *fakeTB) Fatalf(format string, args ...any) {
f.fatals = append(f.fatals, fmt.Sprintf(format, args...))
}