package main import ( "fmt" "go/format" "os" "os/exec" "path/filepath" "strings" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) // normalizeGeneratedCode normalizes generated code for comparison with expected output. func normalizeGeneratedCode(code string) string { // Replace package names (generated uses ndpdk, testdata may use ndhost) code = strings.ReplaceAll(code, "package ndhost", "package ndpdk") return code } var _ = Describe("ndpgen CLI", Ordered, func() { var ( testDir string outputDir string ndpgenBin string ) BeforeAll(func() { // Set testdata directory (relative to ndpgen root) testdataDir = filepath.Join(mustGetWd(GinkgoT()), "testdata") // Build the ndpgen binary ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test") cmd := exec.Command("go", "build", "-o", ndpgenBin, ".") cmd.Dir = mustGetWd(GinkgoT()) output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output) DeferCleanup(func() { os.Remove(ndpgenBin) }) }) BeforeEach(func() { var err error testDir, err = os.MkdirTemp("", "ndpgen-test-input-*") Expect(err).ToNot(HaveOccurred()) outputDir, err = os.MkdirTemp("", "ndpgen-test-output-*") Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { os.RemoveAll(testDir) os.RemoveAll(outputDir) }) Describe("CLI flags and behavior", func() { BeforeEach(func() { serviceCode := `package testpkg import "context" //nd:hostservice name=Test permission=test type TestService interface { //nd:hostfunc DoAction(ctx context.Context, input string) (output string, err error) } ` Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) }) It("supports verbose mode", func() { cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) outputStr := string(output) Expect(outputStr).To(ContainSubstring("Input directory:")) Expect(outputStr).To(ContainSubstring("Base output directory:")) Expect(outputStr).To(ContainSubstring("Go output directory:")) Expect(outputStr).To(ContainSubstring("Found 1 host service(s)")) Expect(outputStr).To(ContainSubstring("Generated")) }) It("supports dry-run mode", func() { cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-dry-run") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) Expect(string(output)).To(ContainSubstring("func TestDoAction(")) Expect(filepath.Join(outputDir, "nd_host_test.go")).ToNot(BeAnExistingFile()) }) It("uses default package name 'host'", func() { customOutput, err := os.MkdirTemp("", "mypkg") Expect(err).ToNot(HaveOccurred()) defer os.RemoveAll(customOutput) cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", customOutput) _, err = cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred()) // Go code goes to $output/go/host/ content, err := os.ReadFile(filepath.Join(customOutput, "go", "host", "nd_host_test.go")) Expect(err).ToNot(HaveOccurred()) Expect(string(content)).To(ContainSubstring("package host")) }) It("returns error for invalid input directory", func() { cmd := exec.Command(ndpgenBin, "-input", "/nonexistent/path") output, err := cmd.CombinedOutput() Expect(err).To(HaveOccurred()) Expect(string(output)).To(ContainSubstring("parsing source files")) }) It("handles no annotated services gracefully", func() { Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte("package testpkg\n"), 0600)).To(Succeed()) cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-v") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) Expect(string(output)).To(ContainSubstring("No host services found")) }) It("generates separate files for multiple services", func() { // Remove service.go created by BeforeEach Expect(os.Remove(filepath.Join(testDir, "service.go"))).To(Succeed()) service1 := `package testpkg import "context" //nd:hostservice name=ServiceA permission=a type ServiceA interface { //nd:hostfunc MethodA(ctx context.Context) error } ` service2 := `package testpkg import "context" //nd:hostservice name=ServiceB permission=b type ServiceB interface { //nd:hostfunc MethodB(ctx context.Context) error } ` Expect(os.WriteFile(filepath.Join(testDir, "a.go"), []byte(service1), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(testDir, "b.go"), []byte(service2), 0600)).To(Succeed()) cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) Expect(string(output)).To(ContainSubstring("Found 2 host service(s)")) // Go code goes to $output/go/host/ goHostDir := filepath.Join(outputDir, "go", "host") Expect(filepath.Join(goHostDir, "nd_host_servicea.go")).To(BeAnExistingFile()) Expect(filepath.Join(goHostDir, "nd_host_serviceb.go")).To(BeAnExistingFile()) }) It("generates Go client code by default", func() { cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) // Go client code goes to $output/go/host/ goHostDir := filepath.Join(outputDir, "go", "host") Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile()) // Stub file also generated Expect(filepath.Join(goHostDir, "nd_host_test_stub.go")).To(BeAnExistingFile()) // doc.go in host dir Expect(filepath.Join(goHostDir, "doc.go")).To(BeAnExistingFile()) // go.mod at parent $output/go/ for consolidated module goDir := filepath.Join(outputDir, "go") Expect(filepath.Join(goDir, "go.mod")).To(BeAnExistingFile()) }) }) Describe("code generation", func() { DescribeTable("generates correct client output", func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) { serviceCode := readTestdata(serviceFile) goClientExpected := readTestdata(goClientExpectedFile) rsClientExpected := readTestdata(rsClientExpectedFile) Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) // Generate all client code (Go, Rust) cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) // Verify Go client code (now in $output/go/host/) goHostDir := filepath.Join(outputDir, "go", "host") entries, err := os.ReadDir(goHostDir) Expect(err).ToNot(HaveOccurred()) var goClientFiles []string for _, e := range entries { if !e.IsDir() && !strings.HasSuffix(e.Name(), "_stub.go") && e.Name() != "doc.go" && e.Name() != "go.mod" { goClientFiles = append(goClientFiles, e.Name()) } } Expect(goClientFiles).To(HaveLen(1), "Expected exactly one Go client file, got: %v", goClientFiles) goClientActual, err := os.ReadFile(filepath.Join(goHostDir, goClientFiles[0])) Expect(err).ToNot(HaveOccurred()) formattedGoClientActual, err := format.Source(goClientActual) Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual) // Normalize expected code to match ndpgen output format normalizedExpected := normalizeGeneratedCode(goClientExpected) formattedGoClientExpected, err := format.Source([]byte(normalizedExpected)) Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go") Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch") // Verify Rust client code (now in $output/rust/nd-pdk-host/src/) rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src") rsClientEntries, err := os.ReadDir(rustSrcDir) Expect(err).ToNot(HaveOccurred()) Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs in src/") // Find the client file (not lib.rs) var rsClientName string for _, entry := range rsClientEntries { if entry.Name() != "lib.rs" { rsClientName = entry.Name() break } } Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file") rsClientActual, err := os.ReadFile(filepath.Join(rustSrcDir, rsClientName)) Expect(err).ToNot(HaveOccurred()) Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch") }, Entry("simple string params", "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"), Entry("multiple simple params (int32)", "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"), Entry("struct param with request type", "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"), Entry("mixed simple and complex params", "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"), Entry("method without error", "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"), Entry("no params, error only", "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"), Entry("map and interface types", "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"), Entry("pointer types", "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"), Entry("multiple returns", "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"), Entry("bytes", "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"), Entry("option pattern (value, exists bool)", "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"), ) It("generates the shared Go types package with -shared-types", func() { typesSrc := `package types // ArtistRef references an artist. type ArtistRef struct { ID string ` + "`json:\"id,omitempty\"`" + ` Name string ` + "`json:\"name\"`" + ` } ` Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed()) cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go") out, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out) content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go")) Expect(err).ToNot(HaveOccurred()) Expect(string(content)).To(ContainSubstring("package types")) Expect(string(content)).To(ContainSubstring("type ArtistRef struct {")) }) It("generates compilable client code for comprehensive service", func() { serviceCode := readTestdata("comprehensive_service.go.txt") Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) // Generate client code cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk") output, err := cmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output) // Go code goes to $output/go/host/ goHostDir := filepath.Join(outputDir, "go", "host") // Read generated client code entries, err := os.ReadDir(goHostDir) Expect(err).ToNot(HaveOccurred()) // Find the client file var clientFileName string for _, entry := range entries { name := entry.Name() if name != "doc.go" && name != "go.mod" && !strings.HasSuffix(name, "_stub.go") && strings.HasSuffix(name, ".go") { clientFileName = name break } } Expect(clientFileName).ToNot(BeEmpty(), "Expected to find Go client file") content, err := os.ReadFile(filepath.Join(goHostDir, clientFileName)) Expect(err).ToNot(HaveOccurred()) // Verify key expected content contentStr := string(content) // Should have wasmimport declarations for all methods Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams")) Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam")) Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror")) Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams")) Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns")) // Should have response types for methods with complex returns (private types in client code) Expect(contentStr).To(ContainSubstring("type comprehensiveSimpleParamsResponse struct")) Expect(contentStr).To(ContainSubstring("type comprehensiveMultipleReturnsResponse struct")) // Should have wrapper functions Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams(")) Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()")) Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()")) // Create a plugin directory with proper import structure pluginDir := filepath.Join(outputDir, "plugin") Expect(os.MkdirAll(pluginDir, 0750)).To(Succeed()) // go.mod is at parent $output/go/ for consolidated module goDir := filepath.Join(outputDir, "go") // Create go.mod for the plugin that imports the generated library goMod := fmt.Sprintf(`module testplugin go 1.25 require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 replace github.com/navidrome/navidrome/plugins/pdk/go => %s `, goDir) Expect(os.WriteFile(filepath.Join(pluginDir, "go.mod"), []byte(goMod), 0600)).To(Succeed()) // Add a simple main function that imports and uses the ndpdk package mainGo := `package main import ndpdk "github.com/navidrome/navidrome/plugins/pdk/go/host" func main() {} // Use some functions to ensure import is not unused var _ = ndpdk.ComprehensiveNoParams ` Expect(os.WriteFile(filepath.Join(pluginDir, "main.go"), []byte(mainGo), 0600)).To(Succeed()) // Tidy dependencies for the generated go library goTidyLibCmd := exec.Command("go", "mod", "tidy") goTidyLibCmd.Dir = goDir goTidyLibOutput, err := goTidyLibCmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "go mod tidy (library) failed: %s", goTidyLibOutput) // Tidy dependencies for the plugin goTidyCmd := exec.Command("go", "mod", "tidy") goTidyCmd.Dir = pluginDir goTidyOutput, err := goTidyCmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "go mod tidy (plugin) failed: %s", goTidyOutput) // Build as WASM plugin - this validates the client code compiles correctly buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".") buildCmd.Dir = pluginDir buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") buildOutput, err := buildCmd.CombinedOutput() Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput) // Verify .wasm file was created Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile()) }) }) }) var testdataDir string func readTestdata(filename string) string { content, err := os.ReadFile(filepath.Join(testdataDir, filename)) Expect(err).ToNot(HaveOccurred(), "Failed to read testdata file: %s", filename) return string(content) } func mustGetWd(t FullGinkgoTInterface) string { dir, err := os.Getwd() if err != nil { t.Fatal(err) } // Look for ndpgen's own go.mod (the subproject root) for { goModPath := filepath.Join(dir, "go.mod") if _, err := os.Stat(goModPath); err == nil { // Check if this is the ndpgen go.mod by reading it content, err := os.ReadFile(goModPath) if err == nil && strings.Contains(string(content), "plugins/cmd/ndpgen") { return dir } } parent := filepath.Dir(dir) if parent == dir { t.Fatal("could not find ndpgen project root") } dir = parent } }