583 lines
17 KiB
Go
583 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
|
|
)
|
|
|
|
const testWebhookSecret = "test-webhook-secret-123"
|
|
|
|
func computeHMAC(body, secret string) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(body))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func newWebhookTestRouter() (*mockUserStore, *mockRunnerStore, *mockRunnerPodManager, *mockForgejoManager, http.Handler) {
|
|
kv := newMockValidator()
|
|
us := newMockUserStore()
|
|
rs := newMockRunnerStore()
|
|
rp := newMockRunnerPodManager()
|
|
fm := newMockForgejoManager()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
srv := &Server{
|
|
Store: kv,
|
|
K8s: newMockPodManager(),
|
|
Users: us,
|
|
Usage: newMockUsageStore(),
|
|
Runners: rs,
|
|
RunnerPods: rp,
|
|
Forgejo: fm,
|
|
Logger: logger,
|
|
GenerateKey: mockGenerateKey,
|
|
WebhookSecret: testWebhookSecret,
|
|
}
|
|
return us, rs, rp, fm, NewRouter(srv)
|
|
}
|
|
|
|
func makeWebhookPayload(t *testing.T, comment, owner, repo string, issueNumber int) string {
|
|
t.Helper()
|
|
payload := map[string]interface{}{
|
|
"action": "created",
|
|
"comment": map[string]interface{}{
|
|
"body": comment,
|
|
"created_at": time.Now().UTC().Format(time.RFC3339),
|
|
"user": map[string]string{"login": "someone"},
|
|
},
|
|
"issue": map[string]interface{}{
|
|
"number": issueNumber,
|
|
"title": "Test Issue",
|
|
},
|
|
"repository": map[string]interface{}{
|
|
"full_name": owner + "/" + repo,
|
|
"name": repo,
|
|
"default_branch": "main",
|
|
"owner": map[string]string{"login": owner},
|
|
},
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func doWebhookRequest(router http.Handler, body, secret, deliveryID, eventType string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body))
|
|
req.Header.Set("X-Forgejo-Signature", computeHMAC(body, secret))
|
|
req.Header.Set("X-Forgejo-Delivery", deliveryID)
|
|
req.Header.Set("X-Forgejo-Event", eventType)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func TestWebhook_Success(t *testing.T) {
|
|
us, rs, rp, fm, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
body := makeWebhookPayload(t, "@claude implement the auth system", "alice", "myrepo", 42)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-001", "issue_comment")
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var runner model.Runner
|
|
decodeJSON(t, rr, &runner)
|
|
if runner.User != "alice" {
|
|
t.Errorf("expected user alice, got %s", runner.User)
|
|
}
|
|
if runner.RepoURL != "alice/myrepo" {
|
|
t.Errorf("expected repo alice/myrepo, got %s", runner.RepoURL)
|
|
}
|
|
if runner.Status != model.RunnerStatusPodCreating {
|
|
t.Errorf("expected status pod_creating, got %s", runner.Status)
|
|
}
|
|
if runner.WebhookDeliveryID != "delivery-001" {
|
|
t.Errorf("expected delivery id delivery-001, got %s", runner.WebhookDeliveryID)
|
|
}
|
|
if !strings.Contains(runner.Task, "implement") {
|
|
t.Errorf("expected task to contain 'implement', got %s", runner.Task)
|
|
}
|
|
|
|
rs.mu.Lock()
|
|
count := len(rs.runners)
|
|
rs.mu.Unlock()
|
|
if count != 1 {
|
|
t.Errorf("expected 1 runner in store, got %d", count)
|
|
}
|
|
|
|
rp.mu.Lock()
|
|
podCount := len(rp.pods)
|
|
rp.mu.Unlock()
|
|
if podCount != 1 {
|
|
t.Errorf("expected 1 pod created, got %d", podCount)
|
|
}
|
|
|
|
fm.mu.Lock()
|
|
commentCount := len(fm.issueComments)
|
|
fm.mu.Unlock()
|
|
if commentCount != 1 {
|
|
t.Errorf("expected 1 issue comment, got %d", commentCount)
|
|
}
|
|
}
|
|
|
|
func TestWebhook_InvalidSignature(t *testing.T) {
|
|
us, _, _, _, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
|
rr := doWebhookRequest(router, body, "wrong-secret", "delivery-002", "issue_comment")
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_MissingSignature(t *testing.T) {
|
|
_, _, _, _, router := newWebhookTestRouter()
|
|
|
|
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body))
|
|
req.Header.Set("X-Forgejo-Delivery", "delivery-003")
|
|
req.Header.Set("X-Forgejo-Event", "issue_comment")
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_MissingDeliveryID(t *testing.T) {
|
|
_, _, _, _, router := newWebhookTestRouter()
|
|
|
|
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body))
|
|
req.Header.Set("X-Forgejo-Signature", computeHMAC(body, testWebhookSecret))
|
|
req.Header.Set("X-Forgejo-Event", "issue_comment")
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_DuplicateDelivery(t *testing.T) {
|
|
us, _, _, _, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
|
|
|
rr1 := doWebhookRequest(router, body, testWebhookSecret, "delivery-dupe", "issue_comment")
|
|
if rr1.Code != http.StatusCreated {
|
|
t.Fatalf("first request: expected 201, got %d: %s", rr1.Code, rr1.Body.String())
|
|
}
|
|
|
|
rr2 := doWebhookRequest(router, body, testWebhookSecret, "delivery-dupe", "issue_comment")
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("dupe: expected 200, got %d: %s", rr2.Code, rr2.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
decodeJSON(t, rr2, &resp)
|
|
if resp["status"] != "already_processed" {
|
|
t.Errorf("expected status already_processed, got %s", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestWebhook_UnsupportedEventType(t *testing.T) {
|
|
_, _, _, _, router := newWebhookTestRouter()
|
|
|
|
body := `{"action":"created"}`
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-004", "push")
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
decodeJSON(t, rr, &resp)
|
|
if resp["reason"] != "unsupported event type" {
|
|
t.Errorf("expected reason 'unsupported event type', got %s", resp["reason"])
|
|
}
|
|
}
|
|
|
|
func TestWebhook_NoClaudeCommand(t *testing.T) {
|
|
us, _, _, _, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
body := makeWebhookPayload(t, "just a regular comment", "alice", "myrepo", 1)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-005", "issue_comment")
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
decodeJSON(t, rr, &resp)
|
|
if resp["reason"] != "no @claude command" {
|
|
t.Errorf("expected reason 'no @claude command', got %s", resp["reason"])
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ReplayWindow(t *testing.T) {
|
|
us, _, _, _, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
oldTime := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
|
|
payload := map[string]interface{}{
|
|
"action": "created",
|
|
"comment": map[string]interface{}{
|
|
"body": "@claude implement it",
|
|
"created_at": oldTime,
|
|
"user": map[string]string{"login": "alice"},
|
|
},
|
|
"issue": map[string]interface{}{
|
|
"number": 1,
|
|
"title": "Old Issue",
|
|
},
|
|
"repository": map[string]interface{}{
|
|
"full_name": "alice/myrepo",
|
|
"name": "myrepo",
|
|
"default_branch": "main",
|
|
"owner": map[string]string{"login": "alice"},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
body := string(b)
|
|
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-006", "issue_comment")
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
decodeJSON(t, rr, &resp)
|
|
if resp["reason"] != "event too old" {
|
|
t.Errorf("expected reason 'event too old', got %s", resp["reason"])
|
|
}
|
|
}
|
|
|
|
func TestWebhook_UserNotFound(t *testing.T) {
|
|
_, _, _, _, router := newWebhookTestRouter()
|
|
|
|
body := makeWebhookPayload(t, "@claude implement it", "nonexistent", "myrepo", 1)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-007", "issue_comment")
|
|
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_WithSpinoffConfig(t *testing.T) {
|
|
us, rs, _, fm, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
fm.mu.Lock()
|
|
fm.repoFiles["alice/myrepo/.spinoff.yml"] = []byte("tools: [rust, go]\ncpu: \"4\"\nmem: 8Gi\n")
|
|
fm.mu.Unlock()
|
|
|
|
body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-008", "issue_comment")
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var runner model.Runner
|
|
decodeJSON(t, rr, &runner)
|
|
if runner.Tools != "rust,go" {
|
|
t.Errorf("expected tools 'rust,go', got %s", runner.Tools)
|
|
}
|
|
if runner.CPUReq != "4" {
|
|
t.Errorf("expected cpu 4, got %s", runner.CPUReq)
|
|
}
|
|
if runner.MemReq != "8Gi" {
|
|
t.Errorf("expected mem 8Gi, got %s", runner.MemReq)
|
|
}
|
|
|
|
rs.mu.Lock()
|
|
for _, r := range rs.runners {
|
|
if r.Tools != "rust,go" {
|
|
t.Errorf("stored runner tools expected 'rust,go', got %s", r.Tools)
|
|
}
|
|
}
|
|
rs.mu.Unlock()
|
|
}
|
|
|
|
func TestWebhook_ForgejoSpinoffConfig(t *testing.T) {
|
|
us, _, _, fm, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()})
|
|
|
|
fm.mu.Lock()
|
|
fm.repoFiles["bob/project/.forgejo/spinoff.yml"] = []byte("tools: [node]\ncpu: \"8\"\nmem: 16Gi\n")
|
|
fm.mu.Unlock()
|
|
|
|
body := makeWebhookPayload(t, "@claude implement it", "bob", "project", 5)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-009", "issue_comment")
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var runner model.Runner
|
|
decodeJSON(t, rr, &runner)
|
|
if runner.Tools != "node" {
|
|
t.Errorf("expected tools 'node', got %s", runner.Tools)
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ReviewCommand(t *testing.T) {
|
|
us, _, _, _, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
body := makeWebhookPayload(t, "@claude review the PR changes", "alice", "myrepo", 10)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-010", "issue_comment")
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var runner model.Runner
|
|
decodeJSON(t, rr, &runner)
|
|
if !strings.Contains(runner.Task, "review") {
|
|
t.Errorf("expected task to contain 'review', got %s", runner.Task)
|
|
}
|
|
}
|
|
|
|
func TestWebhook_FixCommand(t *testing.T) {
|
|
us, _, _, _, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
body := makeWebhookPayload(t, "@claude fix the failing tests", "alice", "myrepo", 15)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-011", "issue_comment")
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var runner model.Runner
|
|
decodeJSON(t, rr, &runner)
|
|
if !strings.Contains(runner.Task, "fix") {
|
|
t.Errorf("expected task to contain 'fix', got %s", runner.Task)
|
|
}
|
|
}
|
|
|
|
func TestWebhook_NotConfigured(t *testing.T) {
|
|
kv := newMockValidator()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
srv := &Server{
|
|
Store: kv,
|
|
Logger: logger,
|
|
}
|
|
router := NewRouter(srv)
|
|
|
|
body := `{}`
|
|
rr := doWebhookRequest(router, body, "", "delivery-012", "issue_comment")
|
|
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebhook_CommentPostedOnIssue(t *testing.T) {
|
|
us, _, _, fm, router := newWebhookTestRouter()
|
|
us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()})
|
|
|
|
body := makeWebhookPayload(t, "@claude implement the login flow", "alice", "myrepo", 42)
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-013", "issue_comment")
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
fm.mu.Lock()
|
|
defer fm.mu.Unlock()
|
|
if len(fm.issueComments) != 1 {
|
|
t.Fatalf("expected 1 comment, got %d", len(fm.issueComments))
|
|
}
|
|
c := fm.issueComments[0]
|
|
if c.Owner != "alice" || c.Repo != "myrepo" || c.IssueNumber != 42 {
|
|
t.Errorf("comment on wrong target: %+v", c)
|
|
}
|
|
if !strings.Contains(c.Body, "Builder pod") {
|
|
t.Errorf("expected comment to contain 'Builder pod', got %s", c.Body)
|
|
}
|
|
if strings.Contains(c.Body, "@claude") {
|
|
t.Errorf("bot comment must NOT contain '@claude' (causes webhook loop), got %s", c.Body)
|
|
}
|
|
}
|
|
|
|
func TestWebhook_ActionNotCreated(t *testing.T) {
|
|
_, _, _, _, router := newWebhookTestRouter()
|
|
|
|
payload := map[string]interface{}{
|
|
"action": "edited",
|
|
"comment": map[string]interface{}{
|
|
"body": "@claude implement it",
|
|
"created_at": time.Now().UTC().Format(time.RFC3339),
|
|
"user": map[string]string{"login": "alice"},
|
|
},
|
|
"issue": map[string]interface{}{
|
|
"number": 1,
|
|
"title": "Test",
|
|
},
|
|
"repository": map[string]interface{}{
|
|
"full_name": "alice/myrepo",
|
|
"name": "myrepo",
|
|
"default_branch": "main",
|
|
"owner": map[string]string{"login": "alice"},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(payload)
|
|
body := string(b)
|
|
|
|
rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-014", "issue_comment")
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]string
|
|
decodeJSON(t, rr, &resp)
|
|
if resp["reason"] != "not a new comment" {
|
|
t.Errorf("expected reason 'not a new comment', got %s", resp["reason"])
|
|
}
|
|
}
|
|
|
|
func TestParseClaudeCommand(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
wantNil bool
|
|
action string
|
|
text string
|
|
}{
|
|
{"@claude implement the auth system", false, "implement", "the auth system"},
|
|
{"@claude review the PR changes", false, "review", "the PR changes"},
|
|
{"@claude fix the bug", false, "fix", "the bug"},
|
|
{"@Claude IMPLEMENT it", false, "implement", "it"},
|
|
{"@claude implement", false, "implement", ""},
|
|
{"just a comment", true, "", ""},
|
|
{"mention @claude but no command", true, "", ""},
|
|
{"@claude deploy it", true, "", ""},
|
|
{"please @claude implement feature X", false, "implement", "feature X"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
cmd := parseClaudeCommand(tt.input)
|
|
if tt.wantNil {
|
|
if cmd != nil {
|
|
t.Errorf("expected nil, got %+v", cmd)
|
|
}
|
|
return
|
|
}
|
|
if cmd == nil {
|
|
t.Fatal("expected command, got nil")
|
|
}
|
|
if cmd.Action != tt.action {
|
|
t.Errorf("expected action %q, got %q", tt.action, cmd.Action)
|
|
}
|
|
if cmd.Text != tt.text {
|
|
t.Errorf("expected text %q, got %q", tt.text, cmd.Text)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerifyHMACSignature(t *testing.T) {
|
|
body := []byte(`{"test": true}`)
|
|
secret := "my-secret"
|
|
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
validSig := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
if !verifyHMACSignature(body, validSig, secret) {
|
|
t.Error("expected valid signature to pass")
|
|
}
|
|
if verifyHMACSignature(body, "invalid", secret) {
|
|
t.Error("expected invalid signature to fail")
|
|
}
|
|
if verifyHMACSignature(body, validSig, "wrong-secret") {
|
|
t.Error("expected wrong secret to fail")
|
|
}
|
|
}
|
|
|
|
func TestParseSpinoffConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
tools []string
|
|
cpu string
|
|
mem string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "full config",
|
|
input: "tools: [rust, go]\ncpu: \"4\"\nmem: 8Gi\n",
|
|
tools: []string{"rust", "go"},
|
|
cpu: "4",
|
|
mem: "8Gi",
|
|
},
|
|
{
|
|
name: "tools only",
|
|
input: "tools: [node]\n",
|
|
tools: []string{"node"},
|
|
},
|
|
{
|
|
name: "empty config",
|
|
input: "",
|
|
tools: nil,
|
|
},
|
|
{
|
|
name: "invalid yaml",
|
|
input: "{{invalid",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg, err := parseSpinoffConfig([]byte(tt.input))
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(cfg.Tools) != len(tt.tools) {
|
|
t.Fatalf("expected %d tools, got %d", len(tt.tools), len(cfg.Tools))
|
|
}
|
|
for i, tool := range tt.tools {
|
|
if cfg.Tools[i] != tool {
|
|
t.Errorf("tool %d: expected %q, got %q", i, tool, cfg.Tools[i])
|
|
}
|
|
}
|
|
if tt.cpu != "" && cfg.CPU != tt.cpu {
|
|
t.Errorf("expected cpu %q, got %q", tt.cpu, cfg.CPU)
|
|
}
|
|
if tt.mem != "" && cfg.Mem != tt.mem {
|
|
t.Errorf("expected mem %q, got %q", tt.mem, cfg.Mem)
|
|
}
|
|
})
|
|
}
|
|
}
|