package kiosk import ( "archive/zip" "bytes" "context" "maps" "os" "path/filepath" "slices" "strings" "testing" ) // derivePlugin writes a literal-ID plugin acme.shop with the given go.mod // framework line and README into a temp dir and returns the dir. func derivePlugin(t *testing.T, require, readme string) string { t.Helper() dir := t.TempDir() gomod := "module example.test/sm-shop-plugin\n\ngo 1.27.0\n" if require != "" { gomod += "\nrequire " + FrameworkModule + " " + require + "\n" } files := map[string]string{ "go.mod": gomod, "plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" }`, } if readme != "" { files["README.md"] = readme } writeTree(t, dir, files) return dir } // TestDerive covers README extraction, overrides, framework rules and // requirement minimums. func TestDerive(t *testing.T) { requireGo(t) ctx := context.Background() t.Run("readme text is flattened", func(t *testing.T) { readme := "\n\n# The `acme` *Shop*\n\n" + "Products, [carts](https://example.test/carts) and **orders**\n" + "for `acme` apps, see .\n\n" + "Second paragraph.\n" m, warnings, err := Derive(ctx, derivePlugin(t, "v0.6.0", readme), DeriveOptions{}) if err != nil { t.Fatalf("Derive: %v", err) } if len(warnings) != 0 { t.Errorf("warnings = %v", warnings) } if m.Name != "The acme Shop" { t.Errorf("name = %q", m.Name) } if want := "Products, carts and orders for acme apps, see https://example.test."; m.Description != want { t.Errorf("description = %q, want %q", m.Description, want) } if m.Package != m.Module || m.Module != "example.test/sm-shop-plugin" || m.Framework != "v0.6.0" || m.Schema != SchemaVersion { t.Errorf("manifest = %+v", m) } }) t.Run("readme errors", func(t *testing.T) { cases := []struct{ name, readme, want string }{ {"missing", "", "needs a README.md"}, {"no h1", "## Shop\n\nText.\n", "has no H1"}, {"empty h1", "#\n\nText.\n", "H1"}, {"no summary", "# Shop\n\n## Usage\n\nText.\n", "no summary paragraph"}, {"id as h1", "# acme.shop\n\nText.\n", "--name"}, } for _, c := range cases { _, _, err := Derive(ctx, derivePlugin(t, "v0.6.0", c.readme), DeriveOptions{}) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: err = %v, want %q", c.name, err, c.want) } } }) t.Run("framework rules", func(t *testing.T) { readme := "# Shop\n\nA shop.\n" if _, _, err := Derive(ctx, derivePlugin(t, "", readme), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "does not require "+FrameworkModule) { t.Errorf("no framework require: err = %v", err) } if _, _, err := Derive(ctx, derivePlugin(t, "v0.0.0", readme), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a released version") { t.Errorf("framework v0.0.0: err = %v", err) } pseudo := "v0.6.1-0.20261029101500-abcdefabcdef" m, warnings, err := Derive(ctx, derivePlugin(t, pseudo, readme), DeriveOptions{}) if err != nil { t.Fatalf("pseudo framework: %v", err) } if m.Framework != pseudo || len(warnings) != 1 || !strings.Contains(warnings[0], "pseudo-version") { t.Errorf("pseudo framework: framework %q, warnings %v", m.Framework, warnings) } }) t.Run("overrides", func(t *testing.T) { dir := derivePlugin(t, "v0.6.0", "# acme.shop\n\nA shop.\n") set := func(name, desc *string) (Manifest, error) { t.Helper() if _, _, err := Write(ctx, dir, DeriveOptions{Name: name, Description: desc}); err != nil { return Manifest{}, err } raw, err := os.ReadFile(filepath.Join(dir, FileName)) if err != nil { t.Fatal(err) } return Parse(raw) } str := func(s string) *string { return &s } m, err := set(str(" Shop "), str("Products and orders.")) if err != nil { t.Fatalf("set: %v", err) } if m.Name != "Shop" || m.Description != "Products and orders." || m.Overrides == nil || m.Overrides.Name != "Shop" { t.Fatalf("after set = %+v / %+v", m, m.Overrides) } if m, err = set(nil, nil); err != nil || m.Name != "Shop" || m.Description != "Products and orders." { t.Fatalf("keep: %+v, %v", m, err) } if m, err = set(nil, str("")); err != nil || m.Description != "A shop." || m.Overrides.Description != "" || m.Overrides.Name != "Shop" { t.Fatalf("clear description: %+v / %+v, %v", m, m.Overrides, err) } if _, err = set(str(""), nil); err == nil || !strings.Contains(err.Error(), "--name") { t.Fatalf("clearing the name while the H1 is the ID: err = %v", err) } }) t.Run("corrupt plugin.yaml", func(t *testing.T) { dir := derivePlugin(t, "v0.6.0", "# Shop\n\nA shop.\n") writeTree(t, dir, map[string]string{FileName: "schema: [\n"}) if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "delete") { t.Errorf("err = %v, want the advice to delete and rerun", err) } }) t.Run("not a plugin directory", func(t *testing.T) { if _, _, err := Derive(ctx, filepath.Join(t.TempDir(), "x"), DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a plugin directory") { t.Errorf("missing dir: err = %v", err) } dir := t.TempDir() writeTree(t, dir, map[string]string{"plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.x" }`}) if _, err := moduleRoot(dir); err == nil { t.Skip("a go.mod above the temp dir") } if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "no go.mod") { t.Errorf("no go.mod: err = %v", err) } }) t.Run("minimums", func(t *testing.T) { root := t.TempDir() fw := "\nrequire " + FrameworkModule + " v0.6.0\n\nreplace " + FrameworkModule + " => ../framework\n" writeTree(t, root, map[string]string{ "framework/go.mod": "module " + FrameworkModule + "\n\ngo 1.27.0\n", // A module that requires the framework and holds acme.user. "user/go.mod": "module example.test/sm-user-plugin\n\ngo 1.27.0\n" + fw, "user/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.user" }`, "user/README.md": "# User\n\nUsers.\n", // A module that does not require the framework: never searched. "lib/go.mod": "module example.test/lib\n\ngo 1.27.0\n", "lib/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.lib" }`, "shop/go.mod": "module example.test/sm-shop-plugin\n\ngo 1.27.0\n\nrequire (\n\t" + FrameworkModule + " v0.6.0\n\texample.test/sm-user-plugin v0.1.2\n\texample.test/lib v1.0.0\n)\n\n" + "replace " + FrameworkModule + " => ../framework\n\nreplace example.test/sm-user-plugin => ../user\n\nreplace example.test/lib => ../lib\n", "shop/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" } func (*Plugin) Requires() []string { return []string{"acme.user", "acme.shopadmin", "acme.lib", "acme.soft"} }`, "shop/README.md": "# Shop\n\nA shop.\n", "shop/admin/plugin.go": "package admin\n\ntype Plugin struct{}\n\nfunc (*Plugin) ID() string { return \"acme.shopadmin\" }\n", "shop/admin/README.md": "# Shop admin\n\nAdmin.\n", "shop/testdata/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.soft" }`, }) m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{}) if err != nil { t.Fatalf("Derive: %v", err) } if len(warnings) != 0 { t.Errorf("warnings = %v", warnings) } want := Requires{"acme.user": "v0.1.2", "acme.shopadmin": "", "acme.lib": "", "acme.soft": ""} if len(m.Requires) != len(want) { t.Fatalf("requires = %v, want %v", m.Requires, want) } for id, v := range want { if got, ok := m.Requires[id]; !ok || got != v { t.Errorf("requires[%s] = %q (present %v), want %q", id, got, ok, v) } } nested, _, err := Derive(ctx, filepath.Join(root, "shop", "admin"), DeriveOptions{}) if err != nil { t.Fatalf("Derive nested: %v", err) } if nested.Package != "example.test/sm-shop-plugin/admin" || nested.Module != "example.test/sm-shop-plugin" { t.Errorf("nested = %+v", nested) } // A required plugin module at v0.0.0 is refused, a pseudo-version warns. shopMod := filepath.Join(root, "shop", "go.mod") orig, _ := os.ReadFile(shopMod) for v, want := range map[string]string{"v0.0.0": "not a released version", "v0.1.3-0.20261029101500-abcdefabcdef": ""} { data := strings.Replace(string(orig), "example.test/sm-user-plugin v0.1.2", "example.test/sm-user-plugin "+v, 1) if err := os.WriteFile(shopMod, []byte(data), 0o644); err != nil { t.Fatal(err) } m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{}) if want != "" { if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.user") { t.Errorf("%s: err = %v", v, err) } continue } if err != nil || m.Requires["acme.user"] != v || len(warnings) != 1 || !strings.Contains(warnings[0], "acme.user") { t.Errorf("%s: requires %v, warnings %v, err %v", v, m.Requires, warnings, err) } } }) } func TestPackagePath(t *testing.T) { root := filepath.FromSlash("/src/m") for dir, want := range map[string]string{ "/src/m": "example.test/m", "/src/m/a": "example.test/m/a", "/src/m/a/b/c": "example.test/m/a/b/c", } { got, err := packagePath("example.test/m", root, filepath.FromSlash(dir)) if err != nil || got != want { t.Errorf("packagePath(%s) = %q, %v; want %q", dir, got, err, want) } } } func TestPluginDirs(t *testing.T) { root := t.TempDir() writeTree(t, root, map[string]string{ "plugin.go": "package p", "sub/plugin.go": "package sub", "sub/deeper/plugin.go": "package deeper", "testdata/plugin.go": "package x", "vendor/x/plugin.go": "package x", "node_modules/plugin.go": "package x", ".hidden/plugin.go": "package x", "_skip/plugin.go": "package x", "nested/go.mod": "module example.test/nested\n", "nested/plugin.go": "package nested", "other/notplugin.go": "package other", }) got := pluginDirs(root) slices.Sort(got) want := []string{root, filepath.Join(root, "sub"), filepath.Join(root, "sub", "deeper")} if strings.Join(got, "|") != strings.Join(want, "|") { t.Errorf("pluginDirs = %v, want %v", got, want) } } // TestDeriveMissingReplace: a required plugin module replaced by a local // directory that is not checked out (a plugin developed inside an // application, checked out on its own) gets the version go.mod requires, // fetched from the module proxy, so the manifest is the same in both // layouts. A version the proxy does not have leaves the minimum empty with // a warning. The proxy is a file:// tree; no network is used. func TestDeriveMissingReplace(t *testing.T) { requireGo(t) ctx := context.Background() root := t.TempDir() proxy := filepath.Join(root, "proxy") userMod := "module example.test/sm-user-plugin\n\ngo 1.27.0\n\nrequire " + FrameworkModule + " v0.6.0\n" writeProxyModule(t, proxy, "example.test/sm-user-plugin", "v0.1.2", map[string]string{ "go.mod": userMod, "plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.user" }`, }) modCache := filepath.Join(root, "modcache") t.Cleanup(func() { _ = filepath.WalkDir(modCache, func(path string, d os.DirEntry, err error) error { if err == nil { _ = os.Chmod(path, 0o755) } return nil }) }) t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy)) t.Setenv("GOMODCACHE", modCache) t.Setenv("GOFLAGS", "-modcacherw") t.Setenv("GOSUMDB", "off") t.Setenv("GONOSUMDB", "") t.Setenv("GOPRIVATE", "") t.Setenv("GONOPROXY", "") t.Setenv("GOTOOLCHAIN", "local") shopMod := func(userVersion string) string { return "module example.test/sm-shop-plugin\n\ngo 1.27.0\n\nrequire (\n\t" + FrameworkModule + " v0.6.0\n\texample.test/sm-user-plugin " + userVersion + "\n)\n\n" + "replace " + FrameworkModule + " => ../framework\n\nreplace example.test/sm-user-plugin => ../user\n" } writeTree(t, root, map[string]string{ "framework/go.mod": "module " + FrameworkModule + "\n\ngo 1.27.0\n", "shop/go.mod": shopMod("v0.1.2"), "shop/plugin.go": pluginHead + `func (*Plugin) ID() string { return "acme.shop" } func (*Plugin) Requires() []string { return []string{"acme.user"} }`, "shop/README.md": "# Shop\n\nA shop.\n", }) // ../user is the replace target and does not exist. m, warnings, err := Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{}) if err != nil { t.Fatalf("Derive: %v", err) } if got := m.Requires["acme.user"]; got != "v0.1.2" || len(warnings) != 0 { t.Errorf("requires[acme.user] = %q, warnings %v; want v0.1.2 and none", got, warnings) } // A required version the proxy does not have: no minimum, a warning. writeTree(t, root, map[string]string{"shop/go.mod": shopMod("v0.1.3")}) m, warnings, err = Derive(ctx, filepath.Join(root, "shop"), DeriveOptions{}) if err != nil { t.Fatalf("Derive v0.1.3: %v", err) } if got, ok := m.Requires["acme.user"]; !ok || got != "" || len(warnings) != 1 || !strings.Contains(warnings[0], "not matched") { t.Errorf("v0.1.3: requires[acme.user] = %q (present %v), warnings %v", got, ok, warnings) } } // writeProxyModule adds modPath@version to the file:// GOPROXY tree at // proxyDir: @v/list, .info, .mod (files["go.mod"]) and a .zip whose // entries are prefixed @/. Module paths are lower case, // so no escaping is needed. func writeProxyModule(t *testing.T, proxyDir, modPath, version string, files map[string]string) { t.Helper() dir := filepath.Join(proxyDir, filepath.FromSlash(modPath), "@v") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } var buf bytes.Buffer zw := zip.NewWriter(&buf) names := slices.Sorted(maps.Keys(files)) 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) } for name, body := range map[string][]byte{ "list": []byte(version + "\n"), version + ".info": []byte(`{"Version":"` + version + `","Time":"2026-10-01T00:00:00Z"}`), version + ".mod": []byte(files["go.mod"]), version + ".zip": buf.Bytes(), } { if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil { t.Fatal(err) } } }