Skip to content

Commit 4e13fb0

Browse files
authored
Merge pull request #784 from docker/e2e
test(e2e): add multi-backend, API compat, and concurrency tests
2 parents ccd3f4b + e669f44 commit 4e13fb0

File tree

5 files changed

+532
-278
lines changed

5 files changed

+532
-278
lines changed

e2e/api_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"encoding/json"
7+
"net/http"
8+
"strings"
9+
"testing"
10+
11+
"github.com/docker/model-runner/cmd/cli/desktop"
12+
)
13+
14+
func TestE2E_APIErrors(t *testing.T) {
15+
t.Run("InvalidModel", func(t *testing.T) {
16+
status, body := doJSON(t, http.MethodPost, serverURL+"/engines/v1/chat/completions",
17+
desktop.OpenAIChatRequest{
18+
Model: "nonexistent/model:latest",
19+
Messages: []desktop.OpenAIChatMessage{{Role: "user", Content: "hi"}},
20+
})
21+
if status == http.StatusOK {
22+
t.Fatalf("expected error for invalid model, got 200: %s", body)
23+
}
24+
t.Logf("invalid model: status=%d", status)
25+
})
26+
27+
t.Run("MalformedJSON", func(t *testing.T) {
28+
req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost,
29+
serverURL+"/engines/v1/chat/completions",
30+
strings.NewReader(`{bad json`))
31+
req.Header.Set("Content-Type", "application/json")
32+
resp, err := http.DefaultClient.Do(req)
33+
if err != nil {
34+
t.Fatalf("request failed: %v", err)
35+
}
36+
resp.Body.Close()
37+
if resp.StatusCode == http.StatusOK {
38+
t.Fatal("expected error for malformed JSON")
39+
}
40+
t.Logf("malformed JSON: status=%d", resp.StatusCode)
41+
})
42+
43+
t.Run("EmptyMessages", func(t *testing.T) {
44+
status, body := doJSON(t, http.MethodPost, serverURL+"/engines/v1/chat/completions",
45+
desktop.OpenAIChatRequest{
46+
Model: ggufModel,
47+
Messages: []desktop.OpenAIChatMessage{},
48+
})
49+
// Some backends accept empty messages, some don't — just verify no 500.
50+
if status == http.StatusInternalServerError {
51+
t.Fatalf("unexpected 500 for empty messages: %s", body)
52+
}
53+
t.Logf("empty messages: status=%d", status)
54+
})
55+
}
56+
57+
func TestE2E_ModelLifecycle(t *testing.T) {
58+
model := ggufModel
59+
60+
t.Run("PullIdempotent", func(t *testing.T) {
61+
pullModel(t, model)
62+
output := pullModel(t, model)
63+
if !strings.Contains(output, "Using cached model") {
64+
t.Errorf("expected 'Using cached model' in second pull, got: %s", output)
65+
}
66+
})
67+
68+
t.Run("InspectModel", func(t *testing.T) {
69+
status, body := doJSON(t, http.MethodGet,
70+
serverURL+"/engines/v1/models/"+model, nil)
71+
if status != http.StatusOK {
72+
t.Fatalf("inspect: status=%d body=%s", status, body)
73+
}
74+
var m struct {
75+
ID string `json:"id"`
76+
}
77+
if err := json.Unmarshal(body, &m); err != nil {
78+
t.Fatalf("decode: %v", err)
79+
}
80+
if m.ID == "" {
81+
t.Fatal("empty model ID in inspect response")
82+
}
83+
t.Logf("inspect: id=%s", m.ID)
84+
})
85+
86+
t.Run("OllamaShow", func(t *testing.T) {
87+
status, body := doJSON(t, http.MethodPost, serverURL+"/api/show",
88+
map[string]string{"name": model})
89+
if status != http.StatusOK {
90+
t.Fatalf("show: status=%d body=%s", status, body)
91+
}
92+
var show struct {
93+
Details struct {
94+
Format string `json:"format"`
95+
} `json:"details"`
96+
}
97+
if err := json.Unmarshal(body, &show); err != nil {
98+
t.Fatalf("decode: %v", err)
99+
}
100+
t.Logf("format: %s", show.Details.Format)
101+
})
102+
103+
t.Run("Remove", func(t *testing.T) {
104+
removeModel(t, model)
105+
})
106+
}

e2e/cli_test.go

Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,46 +7,45 @@ import (
77
"testing"
88
)
99

10-
// TestE2E_CLI runs all CLI tests sequentially as subtests to ensure
11-
// correct ordering (pull → list → run → remove).
1210
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-
})
11+
for _, bc := range backends {
12+
bc := bc
13+
t.Run(bc.name, func(t *testing.T) {
14+
t.Run("Pull", func(t *testing.T) {
15+
out, err := runCLI(t, "pull", bc.model)
16+
if err != nil {
17+
t.Fatalf("cli pull failed: %v\noutput: %s", err, out)
18+
}
19+
t.Logf("pull output: %s", out)
20+
})
21+
22+
t.Run("List", func(t *testing.T) {
23+
out, err := runCLI(t, "ls")
24+
if err != nil {
25+
t.Fatalf("cli ls failed: %v\noutput: %s", err, out)
26+
}
27+
if !strings.Contains(out, "smollm2") {
28+
t.Errorf("expected smollm2 in list output, got:\n%s", out)
29+
}
30+
})
31+
32+
t.Run("Run", func(t *testing.T) {
33+
out, err := runCLI(t, "run", bc.model, "Say hi in one word.")
34+
if err != nil {
35+
t.Fatalf("cli run failed: %v\noutput: %s", err, out)
36+
}
37+
if strings.TrimSpace(out) == "" {
38+
t.Fatal("cli run produced empty output")
39+
}
40+
t.Logf("run output: %s", out)
41+
})
42+
43+
t.Run("Remove", func(t *testing.T) {
44+
out, err := runCLI(t, "rm", "-f", bc.model)
45+
if err != nil {
46+
t.Fatalf("cli rm failed: %v\noutput: %s", err, out)
47+
}
48+
})
49+
})
50+
}
5251
}

e2e/concurrency_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"fmt"
7+
"testing"
8+
)
9+
10+
// TestE2E_ConcurrentRequests sends parallel chat completions to verify
11+
// the scheduler handles concurrent slot allocation correctly.
12+
func TestE2E_ConcurrentRequests(t *testing.T) {
13+
model := ggufModel
14+
pullModel(t, model)
15+
t.Cleanup(func() {
16+
removeModel(t, model)
17+
})
18+
19+
for i := range 5 {
20+
t.Run(fmt.Sprintf("request-%d", i), func(t *testing.T) {
21+
t.Parallel()
22+
resp := chatCompletion(t, model, "Say a single word.")
23+
if len(resp.Choices) == 0 || resp.Choices[0].Message.Content == "" {
24+
t.Fatal("empty response")
25+
}
26+
})
27+
}
28+
}

0 commit comments

Comments
 (0)