Skip to content

Commit 12c8c4b

Browse files
authored
test: consolidates e2e-ish aigw cli tests into e2e-aigw (#1359)
1 parent 2d516f8 commit 12c8c4b

File tree

9 files changed

+172
-369
lines changed

9 files changed

+172
-369
lines changed

.github/workflows/build_and_test.yaml

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -67,32 +67,7 @@ jobs:
6767
~/go/pkg/mod
6868
~/go/bin
6969
key: unittest-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }}-${{ matrix.os }}
70-
71-
# This runs ollama server to be used in `aigw run` end-to-end tests.
72-
# The test case using it will be skipped if ollama is not available.
73-
# Since installing it and pulling the model takes a while, we do it only for Linux runners.
74-
- name: Start Ollama server
75-
if: matrix.os == 'ubuntu-latest'
76-
run: |
77-
curl -fsSL https://ollama.com/install.sh | sh && sudo systemctl stop ollama
78-
nohup ollama serve > ollama.log 2>&1 &
79-
timeout 30 sh -c 'until nc -z localhost 11434; do sleep 1; done'
80-
grep _MODEL .env.ollama | cut -d= -f2 | xargs -I{} ollama pull {}
81-
env:
82-
OLLAMA_CONTEXT_LENGTH: 131072 # Larger context for goose
83-
OLLAMA_HOST: 0.0.0.0
84-
# Download Envoy via func-e using implicit default version `aigw` would
85-
# otherwise need to download during test runs.
86-
- name: Download Envoy via func-e
87-
run: go tool -modfile=tools/go.mod func-e run --version
88-
env:
89-
FUNC_E_HOME: /tmp/envoy-gateway # hard-coded directory in EG
90-
- env:
91-
TEST_AWS_ACCESS_KEY_ID: ${{ secrets.AWS_BEDROCK_USER_AWS_ACCESS_KEY_ID }}
92-
TEST_AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_BEDROCK_USER_AWS_SECRET_ACCESS_KEY }}
93-
TEST_OPENAI_API_KEY: ${{ secrets.ENVOY_AI_GATEWAY_OPENAI_API_KEY }}
94-
TEST_GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
95-
run: make test-coverage
70+
- run: make test-coverage
9671
- if: failure()
9772
run: cat ollama.log || true
9873
- name: Upload coverage to Codecov
@@ -391,7 +366,10 @@ jobs:
391366
GOOSE_VERSION: v1.8.0
392367
run: |
393368
curl -fsSL https://github.com/block/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash
394-
- run: make test-e2e-aigw
369+
- env:
370+
# This is used to access the GitHub MCP server.
371+
TEST_GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
372+
run: make test-e2e-aigw
395373
- if: failure()
396374
run: cat ollama.log || true
397375

cmd/aigw/run_test.go

Lines changed: 7 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@ import (
2424
"time"
2525

2626
egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
27-
"github.com/modelcontextprotocol/go-sdk/mcp"
28-
"github.com/openai/openai-go"
29-
"github.com/openai/openai-go/option"
3027
"github.com/stretchr/testify/require"
3128
"github.com/tetratelabs/func-e/api"
3229
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -35,84 +32,29 @@ import (
3532

3633
"github.com/envoyproxy/ai-gateway/cmd/extproc/mainlib"
3734
"github.com/envoyproxy/ai-gateway/internal/aigw"
38-
"github.com/envoyproxy/ai-gateway/internal/autoconfig"
3935
"github.com/envoyproxy/ai-gateway/internal/filterapi"
4036
internaltesting "github.com/envoyproxy/ai-gateway/internal/testing"
4137
)
4238

43-
// startupRegexp ensures the status message is written to stderr as we know we are healthy!
44-
var startupRegexp = `Envoy AI Gateway listening on http://localhost:1975 \(admin http://localhost:\d+\) after [^\n]+`
45-
39+
// TestRun verifies that the main run function starts up correctly without making any actual requests.
40+
//
41+
// The real e2e tests are in tests/e2e-aigw.
4642
func TestRun(t *testing.T) {
47-
ollamaModel, err := internaltesting.GetOllamaModel(internaltesting.ChatModel)
48-
if err == nil {
49-
err = internaltesting.CheckIfOllamaReady(ollamaModel)
50-
}
51-
if err != nil {
52-
t.Skip(err)
53-
}
54-
5543
ports := internaltesting.RequireRandomPorts(t, 1)
5644
// TODO: parameterize the main listen port 1975
5745
adminPort := ports[0]
5846

59-
t.Setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
47+
// Note: we do not make any real requests here!
48+
t.Setenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
6049
t.Setenv("OPENAI_API_KEY", "unused")
6150

6251
buffers := internaltesting.DumpLogsOnFail(t, "aigw Stdout", "aigw Stderr")
6352
stdout, stderr := buffers[0], buffers[1]
6453
ctx, cancel := context.WithCancel(t.Context())
6554
defer cleanupRun(t, cancel)
6655

67-
go func() {
68-
opts := runOpts{extProcLauncher: mainlib.Main}
69-
require.NoError(t, run(ctx, cmdRun{Debug: true, AdminPort: adminPort}, opts, stdout, stderr))
70-
}()
71-
72-
client := openai.NewClient(option.WithBaseURL("http://localhost:1975/v1/"))
73-
chatReq := openai.ChatCompletionNewParams{
74-
Messages: []openai.ChatCompletionMessageParamUnion{
75-
openai.UserMessage("Say this is a test"),
76-
},
77-
Model: ollamaModel,
78-
}
79-
80-
t.Run("chat completion", func(t *testing.T) {
81-
internaltesting.RequireEventuallyNoError(t, func() error {
82-
chatCompletion, err := client.Chat.Completions.New(ctx, chatReq)
83-
if err != nil {
84-
return fmt.Errorf("chat completion failed: %w", err)
85-
}
86-
for _, choice := range chatCompletion.Choices {
87-
if choice.Message.Content != "" {
88-
return nil
89-
}
90-
}
91-
return fmt.Errorf("no content in response")
92-
}, 1*time.Minute, 2*time.Second,
93-
"chat completion never succeeded")
94-
})
95-
96-
// By now, we're listening
97-
require.Regexp(t, startupRegexp, stderr.String())
98-
99-
t.Run("access metrics", func(t *testing.T) {
100-
internaltesting.RequireEventuallyNoError(t, func() error {
101-
req, err := http.NewRequest(http.MethodGet,
102-
fmt.Sprintf("http://localhost:%d/metrics", adminPort), nil)
103-
require.NoError(t, err)
104-
resp, err := http.DefaultClient.Do(req)
105-
if err != nil {
106-
return err
107-
}
108-
defer resp.Body.Close()
109-
if resp.StatusCode != http.StatusOK {
110-
return fmt.Errorf("status %d", resp.StatusCode)
111-
}
112-
return nil
113-
}, 1*time.Minute, time.Second,
114-
"metrics endpoint never became available")
115-
})
56+
opts := runOpts{extProcLauncher: func(context.Context, []string, io.Writer) error { return nil }}
57+
require.NoError(t, run(ctx, cmdRun{Debug: true, AdminPort: adminPort}, opts, stdout, stderr))
11658
}
11759

11860
func cleanupRun(t testing.TB, cancel context.CancelFunc) {
@@ -126,73 +68,6 @@ func cleanupRun(t testing.TB, cancel context.CancelFunc) {
12668
}
12769
}
12870

129-
// TestRunMCP runs the AIGW with MCP configured and verifies calling a tool.
130-
// It uses the same MCP config as docker-compose.yaml to ensure consistency.
131-
func TestRunMCP(t *testing.T) {
132-
ports := internaltesting.RequireRandomPorts(t, 1)
133-
// TODO: parameterize the main listen port 1975
134-
adminPort := ports[0]
135-
136-
mcpServers := &autoconfig.MCPServers{
137-
McpServers: map[string]autoconfig.MCPServer{
138-
"kiwi": {
139-
Type: "http",
140-
URL: "https://mcp.kiwi.com",
141-
},
142-
},
143-
}
144-
145-
buffers := internaltesting.DumpLogsOnFail(t, "aigw Stdout", "aigw Stderr")
146-
stdout, stderr := buffers[0], buffers[1]
147-
ctx, cancel := context.WithCancel(t.Context())
148-
defer cleanupRun(t, cancel)
149-
150-
go func() {
151-
opts := runOpts{extProcLauncher: mainlib.Main}
152-
require.NoError(t, run(ctx, cmdRun{Debug: true, AdminPort: adminPort, mcpConfig: mcpServers}, opts, stdout, stderr))
153-
}()
154-
155-
url := fmt.Sprintf("http://localhost:%d/mcp", 1975)
156-
mcpClient := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.1.0"},
157-
&mcp.ClientOptions{})
158-
159-
// Calculate departure date as one week from now
160-
departureDate := time.Now().AddDate(0, 0, 7).Format("02/01/2006")
161-
162-
callTool := &mcp.CallToolParams{
163-
Name: "kiwi__search-flight",
164-
Arguments: map[string]any{
165-
"flyFrom": "NYC",
166-
"flyTo": "LAX",
167-
"departureDate": departureDate,
168-
},
169-
}
170-
171-
t.Run("call tool", func(t *testing.T) {
172-
internaltesting.RequireEventuallyNoError(t, func() error {
173-
session, err := mcpClient.Connect(t.Context(),
174-
&mcp.StreamableClientTransport{Endpoint: url}, nil)
175-
if err != nil {
176-
return fmt.Errorf("connect failed: %w", err)
177-
}
178-
defer session.Close()
179-
180-
resp, err := session.CallTool(t.Context(), callTool)
181-
if err != nil {
182-
return fmt.Errorf("call tool failed: %w", err)
183-
}
184-
if resp.IsError {
185-
return fmt.Errorf("tool returned error response")
186-
}
187-
return nil
188-
}, 2*time.Minute, 2*time.Second,
189-
"MCP tool call never succeeded")
190-
})
191-
192-
// By now, we're listening
193-
require.Regexp(t, startupRegexp, stderr.String())
194-
}
195-
19671
func TestRunExtprocStartFailure(t *testing.T) {
19772
t.Setenv("OPENAI_BASE_URL", "http://localhost:11434/v1")
19873
t.Setenv("OPENAI_API_KEY", "unused")

internal/extensionserver/mcproute_test.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,69 @@ func TestServer_createRoutesForBackendListener(t *testing.T) {
124124
},
125125
expectedRoute: nil,
126126
},
127+
{
128+
name: "with MCP routes",
129+
routes: []*routev3.RouteConfiguration{
130+
{
131+
VirtualHosts: []*routev3.VirtualHost{
132+
{
133+
Name: "test-vh",
134+
Routes: []*routev3.Route{{Name: "normal"}},
135+
},
136+
},
137+
},
138+
{
139+
VirtualHosts: []*routev3.VirtualHost{
140+
{
141+
Name: "mcp-vh",
142+
Domains: []string{"*"},
143+
Routes: []*routev3.Route{
144+
{
145+
Name: internalapi.MCPPerBackendRefHTTPRoutePrefix + "foo/rule/0",
146+
Action: &routev3.Route_Route{
147+
Route: &routev3.RouteAction{ClusterSpecifier: &routev3.RouteAction_Cluster{}},
148+
},
149+
},
150+
{
151+
Name: internalapi.MCPPerBackendRefHTTPRoutePrefix + "bar/rule/1",
152+
Action: &routev3.Route_Route{
153+
Route: &routev3.RouteAction{ClusterSpecifier: &routev3.RouteAction_Cluster{}},
154+
},
155+
},
156+
},
157+
},
158+
},
159+
},
160+
},
161+
expectedRoute: &routev3.RouteConfiguration{
162+
Name: "aigateway-mcp-backend-listener-route-config",
163+
VirtualHosts: []*routev3.VirtualHost{
164+
{
165+
Domains: []string{"*"},
166+
Name: "aigateway-mcp-backend-listener-wildcard",
167+
Routes: []*routev3.Route{
168+
{Name: internalapi.MCPPerBackendRefHTTPRoutePrefix + "foo/rule/0", Action: &routev3.Route_Route{
169+
Route: &routev3.RouteAction{ClusterSpecifier: &routev3.RouteAction_Cluster{}},
170+
}},
171+
{Name: internalapi.MCPPerBackendRefHTTPRoutePrefix + "bar/rule/1", Action: &routev3.Route_Route{
172+
Route: &routev3.RouteAction{ClusterSpecifier: &routev3.RouteAction_Cluster{}},
173+
}},
174+
},
175+
},
176+
},
177+
},
178+
},
127179
}
128180

129181
for _, tt := range tests {
130182
t.Run(tt.name, func(t *testing.T) {
131183
s := &Server{log: testr.New(t)}
132184
route := s.createRoutesForBackendListener(tt.routes)
133-
require.Equal(t, tt.expectedRoute, route)
185+
if tt.expectedRoute == nil {
186+
require.Nil(t, route)
187+
} else {
188+
require.Empty(t, cmp.Diff(tt.expectedRoute, route, protocmp.Transform()))
189+
}
134190
})
135191
}
136192
}

internal/mcpproxy/handlers_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,18 @@ func TestServePOST_JSONRPCRequest(t *testing.T) {
286286
params: &mcp.ListToolsParams{},
287287
expStatusCode: 202,
288288
},
289+
{
290+
name: "initialize invalid param",
291+
method: "initialize",
292+
params: "invalid-param",
293+
expStatusCode: 400,
294+
},
295+
{
296+
name: "initialize without route header",
297+
method: "initialize",
298+
params: &mcp.InitializeParams{},
299+
expStatusCode: 500,
300+
},
289301
{
290302
method: "tools/list",
291303
upstreamResponse: `{"jsonrpc":"2.0","id":"1","result":{"tools":[{"name":"my-tool"},{"name":"test-tool"}]}}`,
@@ -298,6 +310,18 @@ func TestServePOST_JSONRPCRequest(t *testing.T) {
298310
require.Equal(t, "backend1__test-tool", result.Tools[0].Name)
299311
},
300312
},
313+
{
314+
method: "notifications/roots/list_changed",
315+
upstreamResponse: `{"jsonrpc":"2.0","id":"1","result":{}}`,
316+
params: &mcp.RootsListChangedParams{},
317+
expStatusCode: 202,
318+
},
319+
{
320+
name: "notifications/roots/list_changed invalid param",
321+
method: "notifications/roots/list_changed",
322+
params: "invalid-param",
323+
expStatusCode: 400,
324+
},
301325
{
302326
method: "prompts/list",
303327
upstreamResponse: `{"jsonrpc":"2.0","id":"1","result":{"prompts":[{"name":"my-prompt"}]}}`,
@@ -310,6 +334,12 @@ func TestServePOST_JSONRPCRequest(t *testing.T) {
310334
require.Equal(t, "backend1__my-prompt", result.Prompts[0].Name)
311335
},
312336
},
337+
{
338+
name: "prompts/list invalid param type",
339+
method: "prompts/list",
340+
params: "invalid",
341+
expStatusCode: 400,
342+
},
313343
{
314344
method: "resources/list",
315345
upstreamResponse: `{"jsonrpc":"2.0","id":"1","result":{"resources":[{"name":"my-resource"}]}}`,

tests/e2e-aigw/aigw_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func buildAigwOnDemand() (string, error) {
5757
}
5858

5959
// startAIGWCLI starts the aigw CLI as a subprocess with the given config file.
60-
func startAIGWCLI(t *testing.T, aigwBin string, env []string, arg ...string) {
60+
func startAIGWCLI(t *testing.T, aigwBin string, env []string, arg ...string) (adminPort int) {
6161
// aigw has many fixed ports: some are in the envoy subprocess
6262
gatewayPort := 1975
6363

@@ -151,6 +151,7 @@ func startAIGWCLI(t *testing.T, aigwBin string, env []string, arg ...string) {
151151
"MCP endpoint never became available")
152152

153153
t.Log("aigw CLI is ready with MCP endpoint")
154+
return envoyAdmin.Port()
154155
}
155156

156157
// Function to check if a port is in use (returns true if listening).

tests/e2e-aigw/examples_test.go renamed to tests/e2e-aigw/examples_mcp_test.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"net/http"
1313
"os"
1414
"path"
15-
"slices"
1615
"sort"
1716
"testing"
1817
"time"
@@ -34,21 +33,6 @@ var (
3433
"kiwi__feedback-to-devs",
3534
"kiwi__search-flight",
3635
}
37-
allGithubTools = []string{
38-
"github__get_issue",
39-
"github__get_issue_comments",
40-
"github__list_issue_types",
41-
"github__list_issues",
42-
"github__list_pull_requests",
43-
"github__list_sub_issues",
44-
"github__pull_request_read",
45-
"github__search_issues",
46-
"github__search_pull_requests",
47-
}
48-
allTools = func() []string {
49-
combined := append(append([]string{}, allNonGithubTools...), allGithubTools...)
50-
return slices.Sorted(slices.Values(combined))
51-
}()
5236

5337
// Filtered tools based on mcp_example.yaml selectors
5438
filteredNonGithubTools = []string{

0 commit comments

Comments
 (0)