499 lines
15 KiB
Go
499 lines
15 KiB
Go
package generate
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestGenerateCreatesManifestLoader(t *testing.T) {
|
|
projectDir := newProject(t, `
|
|
binary_name = "demo-mcp"
|
|
docs_url = "https://docs.example.com/demo"
|
|
|
|
[bootstrap]
|
|
description = "Demo MCP"
|
|
`)
|
|
|
|
result, err := Generate(Options{ProjectDir: projectDir})
|
|
if err != nil {
|
|
t.Fatalf("Generate returned error: %v", err)
|
|
}
|
|
|
|
if !slices.Equal(result.Files, defaultGeneratedFiles("mcpgen")) {
|
|
t.Fatalf("result files = %v", result.Files)
|
|
}
|
|
|
|
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
|
|
content, err := os.ReadFile(generatedPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile generated manifest: %v", err)
|
|
}
|
|
|
|
for _, snippet := range []string{
|
|
"// Code generated by mcp-framework generate. DO NOT EDIT.",
|
|
"package mcpgen",
|
|
"import fwmanifest \"gitea.lclr.dev/AI/mcp-framework/manifest\"",
|
|
"const embeddedManifest = ",
|
|
"func LoadManifest(startDir string) (fwmanifest.File, string, error) {",
|
|
"return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)",
|
|
`binary_name = \"demo-mcp\"`,
|
|
} {
|
|
if !strings.Contains(string(content), snippet) {
|
|
t.Fatalf("generated manifest.go missing snippet %q:\n%s", snippet, content)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateCreatesP1Helpers(t *testing.T) {
|
|
projectDir := newProject(t, `
|
|
binary_name = "demo-mcp"
|
|
docs_url = "https://docs.example.com/demo"
|
|
|
|
[update]
|
|
driver = "gitea"
|
|
repository = "org/demo-mcp"
|
|
base_url = "https://gitea.example.com"
|
|
asset_name_template = "{binary}-{os}-{arch}{ext}"
|
|
|
|
[secret_store]
|
|
backend_policy = "env-only"
|
|
|
|
[bootstrap]
|
|
description = "Demo MCP"
|
|
`)
|
|
|
|
result, err := Generate(Options{ProjectDir: projectDir})
|
|
if err != nil {
|
|
t.Fatalf("Generate returned error: %v", err)
|
|
}
|
|
|
|
wantFiles := []string{
|
|
filepath.Join("mcpgen", "manifest.go"),
|
|
filepath.Join("mcpgen", "metadata.go"),
|
|
filepath.Join("mcpgen", "secretstore.go"),
|
|
filepath.Join("mcpgen", "update.go"),
|
|
}
|
|
if !slices.Equal(result.Files, wantFiles) {
|
|
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
|
|
}
|
|
|
|
metadata, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "metadata.go"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile metadata.go: %v", err)
|
|
}
|
|
for _, snippet := range []string{
|
|
`const BinaryName = "demo-mcp"`,
|
|
`const DefaultDescription = "Demo MCP"`,
|
|
`const DocsURL = "https://docs.example.com/demo"`,
|
|
"func BootstrapInfo(startDir string) (fwmanifest.BootstrapMetadata, string, error) {",
|
|
"func ScaffoldInfo(startDir string) (fwmanifest.ScaffoldMetadata, string, error) {",
|
|
} {
|
|
if !strings.Contains(string(metadata), snippet) {
|
|
t.Fatalf("metadata.go missing snippet %q:\n%s", snippet, metadata)
|
|
}
|
|
}
|
|
|
|
update, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "update.go"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile update.go: %v", err)
|
|
}
|
|
for _, snippet := range []string{
|
|
"func UpdateOptions(version string, stdout io.Writer) (fwupdate.Options, error) {",
|
|
"func UpdateOptionsFrom(startDir string, version string, stdout io.Writer) (fwupdate.Options, error) {",
|
|
"func RunUpdate(ctx context.Context, args []string, version string, stdout io.Writer) error {",
|
|
"ReleaseSource:",
|
|
} {
|
|
if !strings.Contains(string(update), snippet) {
|
|
t.Fatalf("update.go missing snippet %q:\n%s", snippet, update)
|
|
}
|
|
}
|
|
|
|
secretstore, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile secretstore.go: %v", err)
|
|
}
|
|
for _, snippet := range []string{
|
|
"type SecretStoreOptions struct {",
|
|
"func OpenSecretStore(options SecretStoreOptions) (fwsecretstore.Store, error) {",
|
|
"func DescribeSecretRuntime(options SecretStoreOptions) (fwsecretstore.RuntimeDescription, error) {",
|
|
"func PreflightSecretStore(options SecretStoreOptions) (fwsecretstore.PreflightReport, error) {",
|
|
"ManifestLoader:",
|
|
} {
|
|
if !strings.Contains(string(secretstore), snippet) {
|
|
t.Fatalf("secretstore.go missing snippet %q:\n%s", snippet, secretstore)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateCreatesConfigHelpersFromManifestFields(t *testing.T) {
|
|
projectDir := newProject(t, `
|
|
binary_name = "demo-mcp"
|
|
|
|
[[config.fields]]
|
|
name = "base_url"
|
|
flag = "base-url"
|
|
env = "BASE_URL"
|
|
config_key = "base_url"
|
|
type = "url"
|
|
label = "Graylog URL"
|
|
required = true
|
|
sources = ["flag", "env", "config"]
|
|
|
|
[[config.fields]]
|
|
name = "api_token"
|
|
flag = "api-token"
|
|
env = "API_TOKEN"
|
|
secret_key_template = "profile/{profile}/api-token"
|
|
type = "secret"
|
|
label = "API token"
|
|
required = true
|
|
sources = ["flag", "env", "secret"]
|
|
`)
|
|
|
|
result, err := Generate(Options{ProjectDir: projectDir})
|
|
if err != nil {
|
|
t.Fatalf("Generate returned error: %v", err)
|
|
}
|
|
|
|
wantFiles := generatedFilesWithConfig("mcpgen")
|
|
if !slices.Equal(result.Files, wantFiles) {
|
|
t.Fatalf("result files = %v, want %v", result.Files, wantFiles)
|
|
}
|
|
|
|
config, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "config.go"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile config.go: %v", err)
|
|
}
|
|
for _, snippet := range []string{
|
|
"type ConfigFlags struct {",
|
|
"func AddConfigFlags(fs *flag.FlagSet) ConfigFlags {",
|
|
"func ConfigFlagValues(flags ConfigFlags) map[string]string {",
|
|
"func ResolveFieldSpecs(profile string) []fwcli.FieldSpec {",
|
|
"func SetupFields(existing map[string]string) []fwcli.SetupField {",
|
|
`fs.String("base-url", "", "Graylog URL")`,
|
|
`SecretKey: replaceProfile("profile/{profile}/api-token", profile)`,
|
|
"fwcli.SetupFieldURL",
|
|
"fwcli.SetupFieldSecret",
|
|
} {
|
|
if !strings.Contains(string(config), snippet) {
|
|
t.Fatalf("config.go missing snippet %q:\n%s", snippet, config)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateIsIdempotentAndCheckDetectsDrift(t *testing.T) {
|
|
projectDir := newProject(t, `binary_name = "demo-mcp"`)
|
|
|
|
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
|
t.Fatalf("first Generate returned error: %v", err)
|
|
}
|
|
generatedPath := filepath.Join(projectDir, "mcpgen", "manifest.go")
|
|
first, err := os.ReadFile(generatedPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile first generated file: %v", err)
|
|
}
|
|
|
|
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
|
t.Fatalf("second Generate returned error: %v", err)
|
|
}
|
|
second, err := os.ReadFile(generatedPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile second generated file: %v", err)
|
|
}
|
|
if string(second) != string(first) {
|
|
t.Fatalf("second generation changed content")
|
|
}
|
|
|
|
if _, err := Generate(Options{ProjectDir: projectDir, Check: true}); err != nil {
|
|
t.Fatalf("check after generation returned error: %v", err)
|
|
}
|
|
|
|
if err := os.WriteFile(generatedPath, append(second, []byte("// drift\n")...), 0o600); err != nil {
|
|
t.Fatalf("WriteFile drift: %v", err)
|
|
}
|
|
|
|
_, err = Generate(Options{ProjectDir: projectDir, Check: true})
|
|
if !errors.Is(err, ErrGeneratedFilesOutdated) {
|
|
t.Fatalf("check error = %v, want ErrGeneratedFilesOutdated", err)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSupportsManifestAndPackageFlags(t *testing.T) {
|
|
projectDir := t.TempDir()
|
|
manifestPath := filepath.Join(projectDir, "config", "custom.toml")
|
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll manifest dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(manifestPath, []byte(`binary_name = "demo-mcp"`), 0o600); err != nil {
|
|
t.Fatalf("WriteFile manifest: %v", err)
|
|
}
|
|
|
|
result, err := Generate(Options{
|
|
ProjectDir: projectDir,
|
|
ManifestPath: manifestPath,
|
|
PackageDir: "internal/generated",
|
|
PackageName: "generated",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Generate returned error: %v", err)
|
|
}
|
|
|
|
if !slices.Equal(result.Files, defaultGeneratedFiles(filepath.Join("internal", "generated"))) {
|
|
t.Fatalf("result files = %v", result.Files)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(projectDir, "internal", "generated", "manifest.go"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile generated manifest: %v", err)
|
|
}
|
|
if !strings.Contains(string(content), "package generated") {
|
|
t.Fatalf("generated file should use package name: %s", content)
|
|
}
|
|
}
|
|
|
|
func TestGenerateRejectsInvalidManifest(t *testing.T) {
|
|
projectDir := newProject(t, "[bootstrap\n")
|
|
|
|
_, err := Generate(Options{ProjectDir: projectDir})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse manifest") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGeneratedLoaderFallsBackToEmbeddedManifest(t *testing.T) {
|
|
projectDir := newProject(t, `
|
|
binary_name = "embedded-demo"
|
|
docs_url = "https://docs.example.com/embedded"
|
|
|
|
[update]
|
|
driver = "gitea"
|
|
repository = "org/embedded-demo"
|
|
base_url = "https://gitea.example.com"
|
|
|
|
[secret_store]
|
|
backend_policy = "env-only"
|
|
|
|
[bootstrap]
|
|
description = "Embedded Demo"
|
|
|
|
[[config.fields]]
|
|
name = "base_url"
|
|
flag = "base-url"
|
|
env = "BASE_URL"
|
|
config_key = "base_url"
|
|
type = "url"
|
|
label = "Base URL"
|
|
required = true
|
|
sources = ["flag", "env", "config"]
|
|
|
|
[[config.fields]]
|
|
name = "api_token"
|
|
flag = "api-token"
|
|
env = "API_TOKEN"
|
|
secret_key_template = "profile/{profile}/api-token"
|
|
type = "secret"
|
|
label = "API token"
|
|
required = true
|
|
sources = ["flag", "env", "secret"]
|
|
`)
|
|
writeModule(t, projectDir)
|
|
|
|
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
|
t.Fatalf("Generate returned error: %v", err)
|
|
}
|
|
|
|
if err := os.Remove(filepath.Join(projectDir, "mcp.toml")); err != nil {
|
|
t.Fatalf("Remove runtime manifest: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("go", "test", "-mod=mod", "./...")
|
|
cmd.Dir = projectDir
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go test generated project: %v\n%s", err, output)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSecretStoreIncludesBitwardenCacheOption(t *testing.T) {
|
|
projectDir := newProject(t, `
|
|
binary_name = "demo-mcp"
|
|
|
|
[secret_store]
|
|
backend_policy = "bitwarden-cli"
|
|
`)
|
|
|
|
if _, err := Generate(Options{ProjectDir: projectDir}); err != nil {
|
|
t.Fatalf("Generate returned error: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(projectDir, "mcpgen", "secretstore.go"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile generated secretstore: %v", err)
|
|
}
|
|
text := string(content)
|
|
for _, snippet := range []string{
|
|
"DisableBitwardenCache bool",
|
|
"DisableBitwardenCache: options.DisableBitwardenCache,",
|
|
} {
|
|
if !strings.Contains(text, snippet) {
|
|
t.Fatalf("generated secretstore.go missing %q:\n%s", snippet, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newProject(t *testing.T, manifest string) string {
|
|
t.Helper()
|
|
|
|
projectDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(projectDir, "mcp.toml"), []byte(manifest), 0o600); err != nil {
|
|
t.Fatalf("WriteFile manifest: %v", err)
|
|
}
|
|
return projectDir
|
|
}
|
|
|
|
func defaultGeneratedFiles(packageDir string) []string {
|
|
return []string{
|
|
filepath.Join(packageDir, "manifest.go"),
|
|
filepath.Join(packageDir, "metadata.go"),
|
|
filepath.Join(packageDir, "secretstore.go"),
|
|
filepath.Join(packageDir, "update.go"),
|
|
}
|
|
}
|
|
|
|
func generatedFilesWithConfig(packageDir string) []string {
|
|
return []string{
|
|
filepath.Join(packageDir, "config.go"),
|
|
filepath.Join(packageDir, "manifest.go"),
|
|
filepath.Join(packageDir, "metadata.go"),
|
|
filepath.Join(packageDir, "secretstore.go"),
|
|
filepath.Join(packageDir, "update.go"),
|
|
}
|
|
}
|
|
|
|
func writeModule(t *testing.T, projectDir string) {
|
|
t.Helper()
|
|
|
|
repoRoot, err := filepath.Abs("..")
|
|
if err != nil {
|
|
t.Fatalf("Abs repo root: %v", err)
|
|
}
|
|
|
|
goMod := "module example.com/generated-demo\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/99designs/keyring v1.2.2\n\tgithub.com/BurntSushi/toml v1.6.0\n\tgitea.lclr.dev/AI/mcp-framework v0.0.0\n)\n\nreplace gitea.lclr.dev/AI/mcp-framework => " + filepath.ToSlash(repoRoot) + "\n"
|
|
if err := os.WriteFile(filepath.Join(projectDir, "go.mod"), []byte(goMod), 0o600); err != nil {
|
|
t.Fatalf("WriteFile go.mod: %v", err)
|
|
}
|
|
|
|
goSum, err := os.ReadFile(filepath.Join(repoRoot, "go.sum"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile go.sum: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(projectDir, "go.sum"), goSum, 0o600); err != nil {
|
|
t.Fatalf("WriteFile go.sum: %v", err)
|
|
}
|
|
|
|
testFile := `package main
|
|
|
|
import (
|
|
"flag"
|
|
"io"
|
|
"testing"
|
|
|
|
fwcli "gitea.lclr.dev/AI/mcp-framework/cli"
|
|
fwsecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
|
"example.com/generated-demo/mcpgen"
|
|
fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
|
)
|
|
|
|
func TestGeneratedHelpersUseEmbeddedManifest(t *testing.T) {
|
|
file, source, err := mcpgen.LoadManifest(".")
|
|
if err != nil {
|
|
t.Fatalf("LoadManifest returned error: %v", err)
|
|
}
|
|
if source != fwmanifest.EmbeddedSource {
|
|
t.Fatalf("source = %q, want %q", source, fwmanifest.EmbeddedSource)
|
|
}
|
|
if file.BinaryName != "embedded-demo" {
|
|
t.Fatalf("binary name = %q", file.BinaryName)
|
|
}
|
|
|
|
info, source, err := mcpgen.BootstrapInfo(".")
|
|
if err != nil {
|
|
t.Fatalf("BootstrapInfo returned error: %v", err)
|
|
}
|
|
if source != fwmanifest.EmbeddedSource {
|
|
t.Fatalf("bootstrap source = %q, want %q", source, fwmanifest.EmbeddedSource)
|
|
}
|
|
if info.Description != "Embedded Demo" {
|
|
t.Fatalf("description = %q", info.Description)
|
|
}
|
|
|
|
updateOptions, err := mcpgen.UpdateOptions("1.2.3", io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("UpdateOptions returned error: %v", err)
|
|
}
|
|
if updateOptions.CurrentVersion != "1.2.3" {
|
|
t.Fatalf("current version = %q", updateOptions.CurrentVersion)
|
|
}
|
|
if updateOptions.BinaryName != "embedded-demo" {
|
|
t.Fatalf("update binary name = %q", updateOptions.BinaryName)
|
|
}
|
|
if updateOptions.ReleaseSource.Repository != "org/embedded-demo" {
|
|
t.Fatalf("release repository = %q", updateOptions.ReleaseSource.Repository)
|
|
}
|
|
|
|
store, err := mcpgen.OpenSecretStore(mcpgen.SecretStoreOptions{
|
|
LookupEnv: func(name string) (string, bool) {
|
|
return "secret-from-env", true
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("OpenSecretStore returned error: %v", err)
|
|
}
|
|
value, err := store.GetSecret("profile/default/api-token")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
}
|
|
if value != "secret-from-env" {
|
|
t.Fatalf("secret value = %q", value)
|
|
}
|
|
if fwsecretstore.EffectiveBackendPolicy(store) != fwsecretstore.BackendEnvOnly {
|
|
t.Fatalf("effective backend = %q", fwsecretstore.EffectiveBackendPolicy(store))
|
|
}
|
|
|
|
flags := mcpgen.AddConfigFlags(flag.NewFlagSet("test", flag.ContinueOnError))
|
|
if len(mcpgen.ConfigFlagValues(flags)) != 0 {
|
|
t.Fatalf("empty flags should not return values")
|
|
}
|
|
|
|
specs := mcpgen.ResolveFieldSpecs("default")
|
|
if len(specs) != 2 {
|
|
t.Fatalf("field specs = %d, want 2", len(specs))
|
|
}
|
|
if specs[1].SecretKey != "profile/default/api-token" {
|
|
t.Fatalf("secret key = %q", specs[1].SecretKey)
|
|
}
|
|
|
|
setupFields := mcpgen.SetupFields(map[string]string{"api_token": "stored"})
|
|
if len(setupFields) != 2 {
|
|
t.Fatalf("setup fields = %d, want 2", len(setupFields))
|
|
}
|
|
if setupFields[0].Type != fwcli.SetupFieldURL {
|
|
t.Fatalf("first setup field type = %q", setupFields[0].Type)
|
|
}
|
|
if setupFields[1].ExistingSecret != "stored" {
|
|
t.Fatalf("existing secret = %q", setupFields[1].ExistingSecret)
|
|
}
|
|
}
|
|
`
|
|
if err := os.WriteFile(filepath.Join(projectDir, "main_test.go"), []byte(testFile), 0o600); err != nil {
|
|
t.Fatalf("WriteFile main_test.go: %v", err)
|
|
}
|
|
}
|