package kiosk import ( "context" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "testing" "time" ) // copyTestdata copies testdata/ 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: "

Blog

", 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...)) }