navidrome/plugins/cmd/hostgen/integration_test.go
Deluan ba27a8ceef feat(plugins): generate client wrappers for host functions
Signed-off-by: Deluan <deluan@navidrome.org>
2025-12-31 17:06:28 -05:00

420 lines
16 KiB
Go

package main
import (
"go/format"
"os"
"os/exec"
"path/filepath"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
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)
}
var _ = Describe("hostgen CLI", Ordered, func() {
var (
testDir string
outputDir string
hostgenBin string
)
BeforeAll(func() {
// Set testdata directory
testdataDir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen", "testdata")
// Build the hostgen binary
hostgenBin = filepath.Join(os.TempDir(), "hostgen-test")
cmd := exec.Command("go", "build", "-o", hostgenBin, ".")
cmd.Dir = filepath.Join(mustGetWd(GinkgoT()), "plugins", "cmd", "hostgen")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Failed to build hostgen: %s", output)
DeferCleanup(func() {
os.Remove(hostgenBin)
})
})
BeforeEach(func() {
var err error
testDir, err = os.MkdirTemp("", "hostgen-test-input-*")
Expect(err).ToNot(HaveOccurred())
outputDir, err = os.MkdirTemp("", "hostgen-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(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-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("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(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-dry-run")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
Expect(string(output)).To(ContainSubstring("RegisterTestHostFunctions"))
Expect(filepath.Join(outputDir, "test_gen.go")).ToNot(BeAnExistingFile())
})
It("infers package name from output directory", func() {
customOutput, err := os.MkdirTemp("", "mypkg")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(customOutput)
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", customOutput)
_, err = cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
content, err := os.ReadFile(filepath.Join(customOutput, "test_gen.go"))
Expect(err).ToNot(HaveOccurred())
Expect(string(content)).To(ContainSubstring("package mypkg"))
})
It("returns error for invalid input directory", func() {
cmd := exec.Command(hostgenBin, "-input", "/nonexistent/path")
output, err := cmd.CombinedOutput()
Expect(err).To(HaveOccurred())
Expect(string(output)).To(ContainSubstring("Error 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(hostgenBin, "-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(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-v")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
Expect(string(output)).To(ContainSubstring("Found 2 host service(s)"))
Expect(filepath.Join(outputDir, "servicea_gen.go")).To(BeAnExistingFile())
Expect(filepath.Join(outputDir, "serviceb_gen.go")).To(BeAnExistingFile())
})
It("generates only host code with -host-only flag", func() {
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-host-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
Expect(filepath.Join(outputDir, "test_gen.go")).To(BeAnExistingFile())
Expect(filepath.Join(outputDir, "go")).ToNot(BeADirectory())
})
It("generates only client code with -plugin-only flag", func() {
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "main", "-plugin-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Host code should not exist in output root
entries, err := os.ReadDir(outputDir)
Expect(err).ToNot(HaveOccurred())
var genFiles []string
for _, e := range entries {
if e.Name() != "go" {
genFiles = append(genFiles, e.Name())
}
}
Expect(genFiles).To(BeEmpty(), "Expected no host code files, found: %v", genFiles)
// Client code should exist in go/ subdirectory
Expect(filepath.Join(outputDir, "go")).To(BeADirectory())
Expect(filepath.Join(outputDir, "go", "nd_host_test.go")).To(BeAnExistingFile())
})
It("generates both host and client code by default", func() {
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Host code in output root
Expect(filepath.Join(outputDir, "test_gen.go")).To(BeAnExistingFile())
// Client code in go/ subdirectory
Expect(filepath.Join(outputDir, "go")).To(BeADirectory())
Expect(filepath.Join(outputDir, "go", "nd_host_test.go")).To(BeAnExistingFile())
})
It("rejects using both -host-only and -plugin-only together", func() {
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-host-only", "-plugin-only")
output, err := cmd.CombinedOutput()
Expect(err).To(HaveOccurred())
Expect(string(output)).To(ContainSubstring("-host-only and -plugin-only cannot be used together"))
})
})
Describe("code generation", func() {
DescribeTable("generates correct host and client output",
func(serviceFile, hostExpectedFile, clientExpectedFile string) {
serviceCode := readTestdata(serviceFile)
hostExpected := readTestdata(hostExpectedFile)
clientExpected := readTestdata(clientExpectedFile)
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
// Generate both host and client code in one run
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
// Verify host code
entries, err := os.ReadDir(outputDir)
Expect(err).ToNot(HaveOccurred())
var hostFiles []string
for _, e := range entries {
if e.Name() != "go" && !e.IsDir() {
hostFiles = append(hostFiles, e.Name())
}
}
Expect(hostFiles).To(HaveLen(1), "Expected exactly one host file, got: %v", hostFiles)
hostActual, err := os.ReadFile(filepath.Join(outputDir, hostFiles[0]))
Expect(err).ToNot(HaveOccurred())
formattedHostActual, err := format.Source(hostActual)
Expect(err).ToNot(HaveOccurred(), "Generated host code is not valid Go:\n%s", hostActual)
formattedHostExpected, err := format.Source([]byte(hostExpected))
Expect(err).ToNot(HaveOccurred(), "Expected host code is not valid Go")
Expect(string(formattedHostActual)).To(Equal(string(formattedHostExpected)), "Host code mismatch")
// Verify client code
goDir := filepath.Join(outputDir, "go")
clientEntries, err := os.ReadDir(goDir)
Expect(err).ToNot(HaveOccurred())
Expect(clientEntries).To(HaveLen(1), "Expected exactly one client file")
clientActual, err := os.ReadFile(filepath.Join(goDir, clientEntries[0].Name()))
Expect(err).ToNot(HaveOccurred())
formattedClientActual, err := format.Source(clientActual)
Expect(err).ToNot(HaveOccurred(), "Generated client code is not valid Go:\n%s", clientActual)
formattedClientExpected, err := format.Source([]byte(clientExpected))
Expect(err).ToNot(HaveOccurred(), "Expected client code is not valid Go")
Expect(string(formattedClientActual)).To(Equal(string(formattedClientExpected)), "Client code mismatch")
},
Entry("simple string params",
"echo_service.go", "echo_expected.go", "echo_client_expected.go"),
Entry("multiple simple params (int32)",
"math_service.go", "math_expected.go", "math_client_expected.go"),
Entry("struct param with request type",
"store_service.go", "store_expected.go", "store_client_expected.go"),
Entry("mixed simple and complex params",
"list_service.go", "list_expected.go", "list_client_expected.go"),
Entry("method without error",
"counter_service.go", "counter_expected.go", "counter_client_expected.go"),
Entry("no params, error only",
"ping_service.go", "ping_expected.go", "ping_client_expected.go"),
Entry("map and interface types",
"meta_service.go", "meta_expected.go", "meta_client_expected.go"),
Entry("pointer types",
"users_service.go", "users_expected.go", "users_client_expected.go"),
Entry("multiple returns",
"search_service.go", "search_expected.go", "search_client_expected.go"),
Entry("bytes",
"codec_service.go", "codec_expected.go", "codec_client_expected.go"),
)
It("generates compilable host code for comprehensive service", func() {
serviceCode := readTestdata("comprehensive_service.go")
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
// Create go.mod
goMod := "module testpkg\n\ngo 1.23\n\nrequire github.com/extism/go-sdk v1.7.1\n"
Expect(os.WriteFile(filepath.Join(testDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
// Generate host code only
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", testDir, "-package", "testpkg", "-host-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
// Tidy dependencies
goGetCmd := exec.Command("go", "mod", "tidy")
goGetCmd.Dir = testDir
goGetOutput, err := goGetCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "go mod tidy failed: %s", goGetOutput)
// Build
buildCmd := exec.Command("go", "build", ".")
buildCmd.Dir = testDir
buildOutput, err := buildCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Build failed: %s", buildOutput)
})
It("generates compilable client code for comprehensive service", func() {
serviceCode := readTestdata("comprehensive_service.go")
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
// Generate client code only to a separate client directory
clientDir := filepath.Join(outputDir, "client")
cmd := exec.Command(hostgenBin, "-input", testDir, "-output", clientDir, "-package", "main", "-plugin-only")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output)
// Read generated client code
goDir := filepath.Join(clientDir, "go")
entries, err := os.ReadDir(goDir)
Expect(err).ToNot(HaveOccurred())
Expect(entries).To(HaveLen(1), "Expected exactly one generated client file")
content, err := os.ReadFile(filepath.Join(goDir, entries[0].Name()))
Expect(err).ToNot(HaveOccurred())
// Verify key expected content first
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
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()"))
// Move generated file to clientDir root for compilation
Expect(os.Rename(filepath.Join(goDir, entries[0].Name()), filepath.Join(clientDir, "nd_host.go"))).To(Succeed())
// Create go.mod for client code
goMod := "module main\n\ngo 1.23\n\nrequire github.com/extism/go-pdk v1.1.1\n"
Expect(os.WriteFile(filepath.Join(clientDir, "go.mod"), []byte(goMod), 0600)).To(Succeed())
// Add a simple main function for the plugin
mainGo := `package main
func main() {}
`
Expect(os.WriteFile(filepath.Join(clientDir, "main.go"), []byte(mainGo), 0600)).To(Succeed())
// Add type definitions needed by the generated code
typesGo := `package main
type User2 struct {
ID string
Name string
}
type Filter2 struct {
Active bool
}
`
Expect(os.WriteFile(filepath.Join(clientDir, "types.go"), []byte(typesGo), 0600)).To(Succeed())
// Tidy dependencies
goTidyCmd := exec.Command("go", "mod", "tidy")
goTidyCmd.Dir = clientDir
goTidyOutput, err := goTidyCmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "go mod tidy 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 = clientDir
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(clientDir, "plugin.wasm")).To(BeAnExistingFile())
})
})
})
func mustGetWd(t FullGinkgoTInterface) string {
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find project root")
}
dir = parent
}
}