fix(plugins): make generated mock stubs nil-safe for nilable returns (#5909)

The ndpgen mock accessors asserted return types unconditionally, so a plugin
test using Return(nil, ...) — the natural way to model an empty result or a
pagination terminal page — panicked on the untyped-nil type assertion instead
of returning the zero value. Guard pointer, slice, map and any returns in both
the host-service client stubs and the PDK stub, and regenerate.
This commit is contained in:
Deluan Quintão 2026-08-08 21:57:44 -04:00 committed by GitHub
parent a0bf78cdea
commit 5a5311a9c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 250 additions and 41 deletions

View File

@ -27,32 +27,46 @@ func hostFuncMap(svc Service) template.FuncMap {
// Uses private (lowercase) type names for request/response structs.
func clientFuncMap(svc Service) template.FuncMap {
return template.FuncMap{
"lower": strings.ToLower,
"title": strings.Title,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
"requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) },
"responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) },
"formatDoc": formatDoc,
"mockReturnValues": mockReturnValues,
"lower": strings.ToLower,
"title": strings.Title,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
"requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) },
"responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) },
"formatDoc": formatDoc,
"mockReturnBody": mockReturnBody,
}
}
// mockReturnValues generates the testify mock return value accessors for a method.
// For example: args.String(0), args.Bool(1), args.Error(2)
func mockReturnValues(m Method) string {
// mockReturnBody generates the testify mock body lines that extract return values.
// Nil-able returns are guarded so tests can use Return(nil, ...) without the
// type-assertion panic testify produces on an untyped nil.
func mockReturnBody(m Method) string {
var b strings.Builder
var parts []string
idx := 0
for _, r := range m.Returns {
parts = append(parts, mockAccessor(r.Type, idx))
idx++
for idx, r := range m.Returns {
if isNilableType(r.Type) {
name := fmt.Sprintf("r%d", idx)
fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, idx, name, r.Type)
parts = append(parts, name)
} else {
parts = append(parts, mockAccessor(r.Type, idx))
}
}
if m.HasError {
parts = append(parts, fmt.Sprintf("args.Error(%d)", idx))
parts = append(parts, fmt.Sprintf("args.Error(%d)", len(m.Returns)))
}
return strings.Join(parts, ", ")
b.WriteString("\treturn " + strings.Join(parts, ", "))
return b.String()
}
// isNilableType reports whether a nil mock return value is a valid intent for the
// type, rather than a malformed test expectation.
func isNilableType(typ string) bool {
return strings.HasPrefix(typ, "*") || strings.HasPrefix(typ, "[]") ||
strings.HasPrefix(typ, "map[") || typ == "any" || typ == "interface{}"
}
// mockAccessor returns the testify mock accessor call for a given type and index.
@ -725,7 +739,7 @@ func pdkFuncMap() template.FuncMap {
"returnList": pdkReturnList,
"argList": pdkArgList,
"argListWithReceiver": pdkArgListWithReceiver,
"mockReturns": pdkMockReturns,
"mockReturnBody": pdkMockReturnBody,
"constValue": pdkConstValue,
"stubTypeUnderlying": stubTypeUnderlying,
"methodReceiver": pdkMethodReceiver,
@ -834,12 +848,20 @@ func pdkMethodReceiver(receiver, typeName string) string {
}
// pdkMockReturns generates the mock return accessors for a function.
func pdkMockReturns(returns []PDKReturn) string {
func pdkMockReturnBody(returns []PDKReturn) string {
var b strings.Builder
var parts []string
for i, r := range returns {
parts = append(parts, mockAccessorForType(r.Type, i))
if isNilableType(r.Type) {
name := fmt.Sprintf("r%d", i)
fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, i, name, r.Type)
parts = append(parts, name)
} else {
parts = append(parts, mockAccessorForType(r.Type, i))
}
}
return strings.Join(parts, ", ")
b.WriteString("\treturn " + strings.Join(parts, ", "))
return b.String()
}
// mockAccessorForType returns the testify mock accessor for a type.

View File

@ -748,8 +748,9 @@ var _ = Describe("Generator", func() {
// Check bool return uses args.Bool(1)
Expect(codeStr).To(ContainSubstring("args.Bool(1)"))
// Check []byte return uses args.Get(0).([]byte)
Expect(codeStr).To(ContainSubstring("args.Get(0).([]byte)"))
// []byte is nil-able, so it gets a guarded extraction
Expect(codeStr).To(ContainSubstring("var r0 []byte"))
Expect(codeStr).To(ContainSubstring("r0 = v.([]byte)"))
// Check error returns use args.Error(N)
Expect(codeStr).To(ContainSubstring("args.Error("))
@ -1699,6 +1700,97 @@ var _ = Describe("Rust Generation", func() {
Expect(codeStr).NotTo(ContainSubstring("use base64"))
})
})
Describe("nil-safe mock accessors", func() {
Describe("GenerateClientGoStub", func() {
It("guards pointer, slice and map returns so Return(nil, ...) does not panic", func() {
svc := Service{
Name: "Paged",
Interface: "PagedService",
Methods: []Method{
{
Name: "GetPage",
HasError: true,
Params: []Param{NewParam("query", "string")},
Returns: []Param{NewParam("items", "[]Item"), NewParam("next", "*PageOptions")},
},
{
Name: "GetLabels",
HasError: true,
Params: []Param{NewParam("id", "string")},
Returns: []Param{NewParam("labels", "map[string]string")},
},
},
}
code, err := GenerateClientGoStub(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("var r0 []Item"))
Expect(codeStr).To(ContainSubstring("var r1 *PageOptions"))
Expect(codeStr).To(ContainSubstring("if v := args.Get(1); v != nil {"))
Expect(codeStr).To(ContainSubstring("r1 = v.(*PageOptions)"))
Expect(codeStr).To(ContainSubstring("return r0, r1, args.Error(2)"))
Expect(codeStr).To(ContainSubstring("var r0 map[string]string"))
Expect(codeStr).NotTo(ContainSubstring("args.Get(0).([]Item)"))
Expect(codeStr).NotTo(ContainSubstring("args.Get(1).(*PageOptions)"))
})
It("keeps non-nilable returns as inline accessors", func() {
svc := Service{
Name: "Counter",
Interface: "CounterService",
Methods: []Method{
{
Name: "Count",
HasError: true,
Params: []Param{NewParam("id", "string")},
Returns: []Param{NewParam("count", "int64"), NewParam("name", "string")},
},
},
}
code, err := GenerateClientGoStub(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("return args.Get(0).(int64), args.String(1), args.Error(2)"))
Expect(codeStr).NotTo(ContainSubstring("var r0"))
})
})
Describe("GeneratePDKGoStub", func() {
It("guards pointer and slice returns", func() {
symbols := &PDKSymbols{
Functions: []PDKFunc{
{
Name: "NewHTTPRequest",
Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}},
Returns: []PDKReturn{{Type: "*HTTPRequest"}},
},
{
Name: "Input",
Returns: []PDKReturn{{Type: "[]byte"}},
},
},
}
code, err := GeneratePDKGoStub(symbols)
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("var r0 *HTTPRequest"))
Expect(codeStr).To(ContainSubstring("r0 = v.(*HTTPRequest)"))
Expect(codeStr).To(ContainSubstring("var r0 []byte"))
Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)"))
})
})
})
})
func writeFile(path, content string) error {

View File

@ -51,7 +51,7 @@ var {{.Service.Name}}Mock = &mock{{.Service.Name}}Service{}
// {{.Name}} is the mock method for {{$.Service.Name}}{{.Name}}.
func (m *mock{{$.Service.Name}}Service) {{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} {
args := m.Called({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}}{{end}})
return {{mockReturnValues .}}
{{mockReturnBody .}}
}
// {{$.Service.Name}}{{.Name}} delegates to the mock instance.

View File

@ -34,7 +34,7 @@ func ResetMock() {
func {{.Name}}({{paramList .Params}}){{returnList .Returns}} {
{{- if .Returns}}
args := PDKMock.Called({{argList .Params}})
return {{mockReturns .Returns}}
{{mockReturnBody .Returns}}
{{- else}}
PDKMock.Called({{argList .Params}})
{{- end}}

View File

@ -0,0 +1,27 @@
//go:build !wasip1
package host
import (
"testing"
"github.com/stretchr/testify/require"
)
// Return(nil, ...) models "no result"; the generated accessors must yield the
// zero value instead of panicking on the type assertion.
func TestMockNilPointerReturn(t *testing.T) {
HTTPMock.On("Send", HTTPRequest{URL: "http://nil.example"}).Return(nil, nil)
resp, err := HTTPSend(HTTPRequest{URL: "http://nil.example"})
require.NoError(t, err)
require.Nil(t, resp)
}
func TestMockNilSliceReturn(t *testing.T) {
LibraryMock.On("GetAllLibraries").Return(nil, nil)
libs, err := LibraryGetAllLibraries()
require.NoError(t, err)
require.Nil(t, libs)
}

View File

@ -154,7 +154,11 @@ func CacheSetBytes(key string, value []byte, ttlSeconds int64) error {
// GetBytes is the mock method for CacheGetBytes.
func (m *mockCacheService) GetBytes(key string) ([]byte, bool, error) {
args := m.Called(key)
return args.Get(0).([]byte), args.Bool(1), args.Error(2)
var r0 []byte
if v := args.Get(0); v != nil {
r0 = v.([]byte)
}
return r0, args.Bool(1), args.Error(2)
}
// CacheGetBytes delegates to the mock instance.

View File

@ -59,7 +59,11 @@ func ConfigGetInt(key string) (int64, bool) {
// Keys is the mock method for ConfigKeys.
func (m *mockConfigService) Keys(prefix string) []string {
args := m.Called(prefix)
return args.Get(0).([]string)
var r0 []string
if v := args.Get(0); v != nil {
r0 = v.([]string)
}
return r0
}
// ConfigKeys delegates to the mock instance.

View File

@ -43,7 +43,11 @@ var HTTPMock = &mockHTTPService{}
// Send is the mock method for HTTPSend.
func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) {
args := m.Called(request)
return args.Get(0).(*HTTPResponse), args.Error(1)
var r0 *HTTPResponse
if v := args.Get(0); v != nil {
r0 = v.(*HTTPResponse)
}
return r0, args.Error(1)
}
// HTTPSend delegates to the mock instance.

View File

@ -64,7 +64,11 @@ func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error {
// Get is the mock method for KVStoreGet.
func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) {
args := m.Called(key)
return args.Get(0).([]byte), args.Bool(1), args.Error(2)
var r0 []byte
if v := args.Get(0); v != nil {
r0 = v.([]byte)
}
return r0, args.Bool(1), args.Error(2)
}
// KVStoreGet delegates to the mock instance.
@ -81,7 +85,11 @@ func KVStoreGet(key string) ([]byte, bool, error) {
// GetMany is the mock method for KVStoreGetMany.
func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) {
args := m.Called(keys)
return args.Get(0).(map[string][]byte), args.Error(1)
var r0 map[string][]byte
if v := args.Get(0); v != nil {
r0 = v.(map[string][]byte)
}
return r0, args.Error(1)
}
// KVStoreGetMany delegates to the mock instance.
@ -116,7 +124,11 @@ func KVStoreHas(key string) (bool, error) {
// List is the mock method for KVStoreList.
func (m *mockKVStoreService) List(prefix string) ([]string, error) {
args := m.Called(prefix)
return args.Get(0).([]string), args.Error(1)
var r0 []string
if v := args.Get(0); v != nil {
r0 = v.([]string)
}
return r0, args.Error(1)
}
// KVStoreList delegates to the mock instance.

View File

@ -39,7 +39,11 @@ var LibraryMock = &mockLibraryService{}
// GetLibrary is the mock method for LibraryGetLibrary.
func (m *mockLibraryService) GetLibrary(id int32) (*Library, error) {
args := m.Called(id)
return args.Get(0).(*Library), args.Error(1)
var r0 *Library
if v := args.Get(0); v != nil {
r0 = v.(*Library)
}
return r0, args.Error(1)
}
// LibraryGetLibrary delegates to the mock instance.
@ -56,7 +60,11 @@ func LibraryGetLibrary(id int32) (*Library, error) {
// GetAllLibraries is the mock method for LibraryGetAllLibraries.
func (m *mockLibraryService) GetAllLibraries() ([]Library, error) {
args := m.Called()
return args.Get(0).([]Library), args.Error(1)
var r0 []Library
if v := args.Get(0); v != nil {
r0 = v.([]Library)
}
return r0, args.Error(1)
}
// LibraryGetAllLibraries delegates to the mock instance.

View File

@ -31,7 +31,11 @@ var MatcherMock = &mockMatcherService{}
// MatchSongs is the mock method for MatcherMatchSongs.
func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) {
args := m.Called(songs, opts)
return args.Get(0).([]*types.Track), args.Error(1)
var r0 []*types.Track
if v := args.Get(0); v != nil {
r0 = v.([]*types.Track)
}
return r0, args.Error(1)
}
// MatcherMatchSongs delegates to the mock instance.

View File

@ -39,7 +39,11 @@ func SubsonicAPICall(uri string) (string, error) {
// CallRaw is the mock method for SubsonicAPICallRaw.
func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) {
args := m.Called(uri)
return args.String(0), args.Get(1).([]byte), args.Error(2)
var r1 []byte
if v := args.Get(1); v != nil {
r1 = v.([]byte)
}
return args.String(0), r1, args.Error(2)
}
// SubsonicAPICallRaw delegates to the mock instance.

View File

@ -70,7 +70,11 @@ func TaskEnqueue(queueName string, payload []byte) (string, error) {
// Get is the mock method for TaskGet.
func (m *mockTaskService) Get(taskID string) (*TaskInfo, error) {
args := m.Called(taskID)
return args.Get(0).(*TaskInfo), args.Error(1)
var r0 *TaskInfo
if v := args.Get(0); v != nil {
r0 = v.(*TaskInfo)
}
return r0, args.Error(1)
}
// TaskGet delegates to the mock instance.

View File

@ -33,7 +33,11 @@ var UsersMock = &mockUsersService{}
// GetUsers is the mock method for UsersGetUsers.
func (m *mockUsersService) GetUsers() ([]User, error) {
args := m.Called()
return args.Get(0).([]User), args.Error(1)
var r0 []User
if v := args.Get(0); v != nil {
r0 = v.([]User)
}
return r0, args.Error(1)
}
// UsersGetUsers delegates to the mock instance.
@ -49,7 +53,11 @@ func UsersGetUsers() ([]User, error) {
// GetAdmins is the mock method for UsersGetAdmins.
func (m *mockUsersService) GetAdmins() ([]User, error) {
args := m.Called()
return args.Get(0).([]User), args.Error(1)
var r0 []User
if v := args.Get(0); v != nil {
r0 = v.([]User)
}
return r0, args.Error(1)
}
// UsersGetAdmins delegates to the mock instance.

View File

@ -62,7 +62,11 @@ func GetConfig(key string) (string, bool) {
// GetVar GetVar returns the byte slice (if any) associated with `key`.
func GetVar(key string) []byte {
args := PDKMock.Called(key)
return args.Get(0).([]byte)
var r0 []byte
if v := args.Get(0); v != nil {
r0 = v.([]byte)
}
return r0
}
// GetVarInt GetVarInt returns the int associated with `key` (or 0 if none).
@ -74,7 +78,11 @@ func GetVarInt(key string) int {
// Input Input returns a slice of bytes from the host.
func Input() []byte {
args := PDKMock.Called()
return args.Get(0).([]byte)
var r0 []byte
if v := args.Get(0); v != nil {
r0 = v.([]byte)
}
return r0
}
// InputJSON InputJSON returns unmartialed JSON data from the host "input".
@ -108,7 +116,11 @@ func LogMemory(level LogLevel, m Memory) {
// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`.
func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest {
args := PDKMock.Called(method, url)
return args.Get(0).(*HTTPRequest)
var r0 *HTTPRequest
if v := args.Get(0); v != nil {
r0 = v.(*HTTPRequest)
}
return r0
}
func NewMemory(offset uint64, length uint64) Memory {
args := PDKMock.Called(offset, length)
@ -139,7 +151,11 @@ func OutputString(s string) {
// ParamBytes ParamBytes returns bytes from Extism host memory given an offset.
func ParamBytes(offset uint64) []byte {
args := PDKMock.Called(offset)
return args.Get(0).([]byte)
var r0 []byte
if v := args.Get(0); v != nil {
r0 = v.([]byte)
}
return r0
}
// ParamString ParamString returns UTF-8 string data from Extism host memory given an offset.