Skip to content

Commit d06082c

Browse files
authored
Merge pull request #780 from docker/e2e
test(e2e): add end-to-end tests for inference and CLI
2 parents a5436a4 + d68e8a4 commit d06082c

5 files changed

Lines changed: 492 additions & 1 deletion

File tree

.github/workflows/e2e-test.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: E2E Tests
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
branches: [ main ]
7+
push:
8+
branches: [ main ]
9+
10+
jobs:
11+
e2e-test:
12+
runs-on: macos-latest
13+
timeout-minutes: 20
14+
15+
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
18+
with:
19+
submodules: recursive
20+
21+
- name: Set up Go
22+
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417
23+
with:
24+
go-version: 1.25.8
25+
cache: true
26+
27+
- name: Run e2e tests
28+
run: make e2e

Makefile

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ DOCKER_BUILD_ARGS := \
2323
-t $(DOCKER_IMAGE)
2424

2525
# Phony targets grouped by category
26-
.PHONY: build build-cli build-dmr install-cli run clean test integration-tests
26+
.PHONY: build build-cli build-dmr build-llamacpp install-cli run clean test integration-tests e2e
2727
.PHONY: validate validate-all lint help
2828
.PHONY: docker-build docker-build-multiplatform docker-run docker-run-impl
2929
.PHONY: docker-build-vllm docker-run-vllm docker-build-sglang docker-run-sglang
@@ -44,6 +44,10 @@ build-cli:
4444
build-dmr:
4545
go build -ldflags="-s -w" -o dmr ./cmd/dmr
4646

47+
build-llamacpp:
48+
git submodule update --init llamacpp/native
49+
$(MAKE) -C llamacpp build
50+
4751
install-cli:
4852
$(MAKE) -C cmd/cli install
4953

@@ -82,6 +86,18 @@ integration-tests:
8286
go test -v -race -count=1 -tags=integration -run "^TestIntegration" -timeout=5m ./cmd/cli/commands
8387
@echo "Integration tests completed!"
8488

89+
e2e: build-llamacpp build
90+
@echo "Running e2e tests..."
91+
@echo "Checking test naming conventions..."
92+
@INVALID_TESTS=$$(grep "^func Test" e2e/*_test.go | grep -v "^.*:func TestE2E" | grep -v "^.*:func TestMain"); \
93+
if [ -n "$$INVALID_TESTS" ]; then \
94+
echo "Error: Found test functions that don't start with 'TestE2E':"; \
95+
echo "$$INVALID_TESTS" | sed 's/.*func \([^(]*\).*/\1/'; \
96+
exit 1; \
97+
fi
98+
go test -v -count=1 -tags=e2e -run "^TestE2E" -timeout=15m ./e2e/
99+
@echo "E2E tests completed!"
100+
85101
test-docker-ce-installation:
86102
@echo "Testing Docker CE installation..."
87103
@echo "Note: This requires Docker to be running"
@@ -319,6 +335,8 @@ help:
319335
@echo " clean - Clean build artifacts"
320336
@echo " test - Run tests"
321337
@echo " integration-tests - Run integration tests (requires Docker)"
338+
@echo " build-llamacpp - Init submodule and build llama.cpp from source"
339+
@echo " e2e - Run e2e tests (builds llamacpp + server, macOS)"
322340
@echo " test-docker-ce-installation - Test Docker CE installation with CLI plugin"
323341
@echo " validate - Run shellcheck validation"
324342
@echo " validate-all - Run all CI validations locally (lint, test, shellcheck, go mod tidy)"

e2e/cli_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestE2E_CLI runs all CLI tests sequentially as subtests to ensure
11+
// correct ordering (pull → list → run → remove).
12+
func TestE2E_CLI(t *testing.T) {
13+
t.Run("Pull", func(t *testing.T) {
14+
out, err := runCLI(t, "pull", testModel)
15+
if err != nil {
16+
t.Fatalf("cli pull failed: %v\noutput: %s", err, out)
17+
}
18+
t.Logf("pull output: %s", out)
19+
})
20+
21+
t.Run("List", func(t *testing.T) {
22+
out, err := runCLI(t, "ls")
23+
if err != nil {
24+
t.Fatalf("cli ls failed: %v\noutput: %s", err, out)
25+
}
26+
27+
if !strings.Contains(out, "smollm2") {
28+
t.Errorf("expected smollm2 in list output, got:\n%s", out)
29+
}
30+
t.Logf("ls output:\n%s", out)
31+
})
32+
33+
t.Run("Run", func(t *testing.T) {
34+
out, err := runCLI(t, "run", testModel, "Say hi in one word.")
35+
if err != nil {
36+
t.Fatalf("cli run failed: %v\noutput: %s", err, out)
37+
}
38+
39+
if strings.TrimSpace(out) == "" {
40+
t.Fatal("cli run produced empty output")
41+
}
42+
t.Logf("run output: %s", out)
43+
})
44+
45+
t.Run("Remove", func(t *testing.T) {
46+
out, err := runCLI(t, "rm", "-f", testModel)
47+
if err != nil {
48+
t.Fatalf("cli rm failed: %v\noutput: %s", err, out)
49+
}
50+
t.Logf("rm output: %s", out)
51+
})
52+
}

e2e/e2e_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//go:build e2e
2+
3+
// Package e2e contains end-to-end tests that build and run the full
4+
// model-runner stack (server + llama.cpp backend + CLI) from source.
5+
//
6+
// These tests require:
7+
// - The llamacpp submodule to be initialised and built (make build-llamacpp)
8+
// - A successful `make build` so that model-runner, model-cli, and dmr exist
9+
//
10+
// Run with:
11+
//
12+
// go test -v -count=1 -tags=e2e -timeout=15m ./e2e/
13+
package e2e
14+
15+
import (
16+
"context"
17+
"fmt"
18+
"net"
19+
"net/http"
20+
"os"
21+
"os/exec"
22+
"path/filepath"
23+
"strconv"
24+
"testing"
25+
"time"
26+
)
27+
28+
const (
29+
// testModel is small enough to pull quickly in CI.
30+
testModel = "ai/smollm2:135M-Q4_0"
31+
32+
serverStartTimeout = 60 * time.Second
33+
)
34+
35+
var (
36+
// serverURL is the base URL of the running model-runner instance.
37+
serverURL string
38+
// cliBin is the absolute path to the model-cli binary.
39+
cliBin string
40+
)
41+
42+
// TestMain builds the binaries, starts the server (same pattern as dmr),
43+
// and tears it down after all tests complete.
44+
func TestMain(m *testing.M) {
45+
code := run(m)
46+
os.Exit(code)
47+
}
48+
49+
func run(m *testing.M) int {
50+
// go test sets cwd to the package directory (e2e/), so the repo root is ../
51+
root, err := filepath.Abs("..")
52+
if err != nil {
53+
fmt.Fprintf(os.Stderr, "e2e: %v\n", err)
54+
return 1
55+
}
56+
57+
// ── 1. Build binaries ──────────────────────────────────────────────
58+
fmt.Fprintln(os.Stderr, "e2e: building server and CLI...")
59+
if err := makeTarget(root, "build"); err != nil {
60+
fmt.Fprintf(os.Stderr, "e2e: make build failed: %v\n", err)
61+
return 1
62+
}
63+
64+
serverBin := filepath.Join(root, "model-runner")
65+
cliBin = filepath.Join(root, "cmd", "cli", "model-cli")
66+
llamaBin := filepath.Join(root, "llamacpp", "install", "bin")
67+
68+
for _, path := range []string{serverBin, cliBin, llamaBin} {
69+
if _, err := os.Stat(path); err != nil {
70+
fmt.Fprintf(os.Stderr, "e2e: not found: %s\n", path)
71+
return 1
72+
}
73+
}
74+
75+
// ── 2. Start model-runner (same pattern as cmd/dmr) ────────────────
76+
port, err := freePort()
77+
if err != nil {
78+
fmt.Fprintf(os.Stderr, "e2e: %v\n", err)
79+
return 1
80+
}
81+
serverURL = "http://localhost:" + strconv.Itoa(port)
82+
fmt.Fprintf(os.Stderr, "e2e: starting model-runner on port %d\n", port)
83+
84+
ctx, cancel := context.WithCancel(context.Background())
85+
defer cancel()
86+
87+
server := exec.CommandContext(ctx, serverBin)
88+
server.Dir = root
89+
server.Env = append(os.Environ(),
90+
"MODEL_RUNNER_PORT="+strconv.Itoa(port),
91+
"LLAMA_SERVER_PATH="+llamaBin,
92+
)
93+
server.Stdout = os.Stderr
94+
server.Stderr = os.Stderr
95+
96+
if err := server.Start(); err != nil {
97+
fmt.Fprintf(os.Stderr, "e2e: failed to start server: %v\n", err)
98+
return 1
99+
}
100+
defer func() {
101+
cancel()
102+
_ = server.Wait()
103+
}()
104+
105+
// ── 3. Wait for health ─────────────────────────────────────────────
106+
if err := waitForServer(serverURL+"/models", serverStartTimeout); err != nil {
107+
fmt.Fprintf(os.Stderr, "e2e: %v\n", err)
108+
return 1
109+
}
110+
fmt.Fprintf(os.Stderr, "e2e: server ready at %s\n", serverURL)
111+
112+
// ── 4. Run tests ───────────────────────────────────────────────────
113+
return m.Run()
114+
}
115+
116+
func makeTarget(dir, target string) error {
117+
cmd := exec.Command("make", target)
118+
cmd.Dir = dir
119+
cmd.Stdout = os.Stderr
120+
cmd.Stderr = os.Stderr
121+
return cmd.Run()
122+
}
123+
124+
func freePort() (int, error) {
125+
l, err := net.Listen("tcp", "127.0.0.1:0")
126+
if err != nil {
127+
return 0, fmt.Errorf("finding free port: %w", err)
128+
}
129+
defer l.Close()
130+
return l.Addr().(*net.TCPAddr).Port, nil
131+
}
132+
133+
func waitForServer(url string, timeout time.Duration) error {
134+
client := &http.Client{Timeout: 2 * time.Second}
135+
deadline := time.Now().Add(timeout)
136+
for time.Now().Before(deadline) {
137+
resp, err := client.Get(url)
138+
if err == nil {
139+
resp.Body.Close()
140+
if resp.StatusCode == http.StatusOK {
141+
return nil
142+
}
143+
}
144+
time.Sleep(200 * time.Millisecond)
145+
}
146+
return fmt.Errorf("server not ready after %s", timeout)
147+
}
148+
149+
// runCLI executes the model-cli binary with the given arguments and
150+
// MODEL_RUNNER_HOST pointing to the test server. The subprocess is
151+
// cancelled if the test's context expires.
152+
func runCLI(t *testing.T, args ...string) (string, error) {
153+
t.Helper()
154+
cmd := exec.CommandContext(t.Context(), cliBin, args...)
155+
cmd.Env = append(os.Environ(), "MODEL_RUNNER_HOST="+serverURL)
156+
out, err := cmd.CombinedOutput()
157+
return string(out), err
158+
}

0 commit comments

Comments
 (0)