Skip to content

Commit 5ecab7b

Browse files
authored
chore(cli): add single CRUD-style test for tasks (coder#20385)
Adds a single CRUD-style test for tasks CLI using a single `coderdtest` instance.
1 parent df3b1bb commit 5ecab7b

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

cli/exp_task_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,240 @@ package cli_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"net/http"
67
"net/http/httptest"
8+
"slices"
9+
"strings"
710
"sync"
811
"testing"
12+
"time"
913

1014
"github.com/google/uuid"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/require"
17+
"golang.org/x/xerrors"
18+
19+
agentapisdk "github.com/coder/agentapi-sdk-go"
1120

1221
"github.com/coder/coder/v2/agent"
1322
"github.com/coder/coder/v2/agent/agenttest"
23+
"github.com/coder/coder/v2/cli/clitest"
1424
"github.com/coder/coder/v2/coderd/coderdtest"
25+
"github.com/coder/coder/v2/coderd/util/ptr"
1526
"github.com/coder/coder/v2/codersdk"
1627
"github.com/coder/coder/v2/codersdk/agentsdk"
1728
"github.com/coder/coder/v2/provisioner/echo"
1829
"github.com/coder/coder/v2/provisionersdk/proto"
30+
"github.com/coder/coder/v2/testutil"
1931
)
2032

33+
// This test performs an integration-style test for tasks functionality.
34+
//
35+
//nolint:tparallel // The sub-tests of this test must be run sequentially.
36+
func Test_Tasks(t *testing.T) {
37+
t.Parallel()
38+
39+
// Given: a template configured for tasks
40+
var (
41+
ctx = testutil.Context(t, testutil.WaitLong)
42+
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
43+
owner = coderdtest.CreateFirstUser(t, client)
44+
userClient, _ = coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
45+
initMsg = agentapisdk.Message{
46+
Content: "test task input for " + t.Name(),
47+
Id: 0,
48+
Role: "user",
49+
Time: time.Now().UTC(),
50+
}
51+
authToken = uuid.NewString()
52+
echoAgentAPI = startFakeAgentAPI(t, fakeAgentAPIEcho(ctx, t, initMsg, "hello"))
53+
taskTpl = createAITaskTemplate(t, client, owner.OrganizationID, withAgentToken(authToken), withSidebarURL(echoAgentAPI.URL()))
54+
taskName = strings.ReplaceAll(testutil.GetRandomName(t), "_", "-")
55+
)
56+
57+
//nolint:paralleltest // The sub-tests of this test must be run sequentially.
58+
for _, tc := range []struct {
59+
name string
60+
cmdArgs []string
61+
assertFn func(stdout string, userClient *codersdk.Client)
62+
}{
63+
{
64+
name: "create task",
65+
cmdArgs: []string{"exp", "task", "create", "test task input for " + t.Name(), "--name", taskName, "--template", taskTpl.Name},
66+
assertFn: func(stdout string, userClient *codersdk.Client) {
67+
require.Contains(t, stdout, taskName, "task name should be in output")
68+
},
69+
},
70+
{
71+
name: "list tasks after create",
72+
cmdArgs: []string{"exp", "task", "list", "--output", "json"},
73+
assertFn: func(stdout string, userClient *codersdk.Client) {
74+
var tasks []codersdk.Task
75+
err := json.NewDecoder(strings.NewReader(stdout)).Decode(&tasks)
76+
require.NoError(t, err, "list output should unmarshal properly")
77+
require.Len(t, tasks, 1, "expected one task")
78+
require.Equal(t, taskName, tasks[0].Name, "task name should match")
79+
require.Equal(t, initMsg.Content, tasks[0].InitialPrompt, "initial prompt should match")
80+
require.True(t, tasks[0].WorkspaceID.Valid, "workspace should be created")
81+
// For the next test, we need to wait for the workspace to be healthy
82+
ws := coderdtest.MustWorkspace(t, userClient, tasks[0].WorkspaceID.UUID)
83+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
84+
agentClient := agentsdk.New(client.URL, agentsdk.WithFixedToken(authToken))
85+
_ = agenttest.New(t, client.URL, authToken, func(o *agent.Options) {
86+
o.Client = agentClient
87+
})
88+
coderdtest.NewWorkspaceAgentWaiter(t, userClient, tasks[0].WorkspaceID.UUID).WithContext(ctx).WaitFor(coderdtest.AgentsReady)
89+
},
90+
},
91+
{
92+
name: "get task status after create",
93+
cmdArgs: []string{"exp", "task", "status", taskName, "--output", "json"},
94+
assertFn: func(stdout string, userClient *codersdk.Client) {
95+
var task codersdk.Task
96+
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&task), "should unmarshal task status")
97+
require.Equal(t, task.Name, taskName, "task name should match")
98+
// NOTE: task status changes type, this is so this test works with both old and new model
99+
require.Contains(t, []string{"active", "running"}, string(task.Status), "task should be active")
100+
},
101+
},
102+
{
103+
name: "send task message",
104+
cmdArgs: []string{"exp", "task", "send", taskName, "hello"},
105+
// Assertions for this happen in the fake agent API handler.
106+
},
107+
{
108+
name: "read task logs",
109+
cmdArgs: []string{"exp", "task", "logs", taskName, "--output", "json"},
110+
assertFn: func(stdout string, userClient *codersdk.Client) {
111+
var logs []codersdk.TaskLogEntry
112+
require.NoError(t, json.NewDecoder(strings.NewReader(stdout)).Decode(&logs), "should unmarshal task logs")
113+
require.Len(t, logs, 3, "should have 3 logs")
114+
require.Equal(t, logs[0].Content, initMsg.Content, "first message should be the init message")
115+
require.Equal(t, logs[0].Type, codersdk.TaskLogTypeInput, "first message should be an input")
116+
require.Equal(t, logs[1].Content, "hello", "second message should be the sent message")
117+
require.Equal(t, logs[1].Type, codersdk.TaskLogTypeInput, "second message should be an input")
118+
require.Equal(t, logs[2].Content, "hello", "third message should be the echoed message")
119+
require.Equal(t, logs[2].Type, codersdk.TaskLogTypeOutput, "third message should be an output")
120+
},
121+
},
122+
{
123+
name: "delete task",
124+
cmdArgs: []string{"exp", "task", "delete", taskName, "--yes"},
125+
assertFn: func(stdout string, userClient *codersdk.Client) {
126+
// The task should eventually no longer show up in the list of tasks
127+
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
128+
expClient := codersdk.NewExperimentalClient(userClient)
129+
tasks, err := expClient.Tasks(ctx, &codersdk.TasksFilter{})
130+
if !assert.NoError(t, err) {
131+
return false
132+
}
133+
return slices.IndexFunc(tasks, func(task codersdk.Task) bool {
134+
return task.Name == taskName
135+
}) == -1
136+
}, testutil.IntervalMedium)
137+
},
138+
},
139+
} {
140+
t.Run(tc.name, func(t *testing.T) {
141+
var stdout strings.Builder
142+
inv, root := clitest.New(t, tc.cmdArgs...)
143+
inv.Stdout = &stdout
144+
clitest.SetupConfig(t, userClient, root)
145+
require.NoError(t, inv.WithContext(ctx).Run())
146+
if tc.assertFn != nil {
147+
tc.assertFn(stdout.String(), userClient)
148+
}
149+
})
150+
}
151+
}
152+
153+
func fakeAgentAPIEcho(ctx context.Context, t testing.TB, initMsg agentapisdk.Message, want ...string) map[string]http.HandlerFunc {
154+
t.Helper()
155+
var mmu sync.RWMutex
156+
msgs := []agentapisdk.Message{initMsg}
157+
wantCpy := make([]string, len(want))
158+
copy(wantCpy, want)
159+
t.Cleanup(func() {
160+
mmu.Lock()
161+
defer mmu.Unlock()
162+
if !t.Failed() {
163+
assert.Empty(t, wantCpy, "not all expected messages received: missing %v", wantCpy)
164+
}
165+
})
166+
writeAgentAPIError := func(w http.ResponseWriter, err error, status int) {
167+
w.WriteHeader(status)
168+
_ = json.NewEncoder(w).Encode(agentapisdk.ErrorModel{
169+
Errors: ptr.Ref([]agentapisdk.ErrorDetail{
170+
{
171+
Message: ptr.Ref(err.Error()),
172+
},
173+
}),
174+
})
175+
}
176+
return map[string]http.HandlerFunc{
177+
"/status": func(w http.ResponseWriter, r *http.Request) {
178+
w.Header().Set("Content-Type", "application/json")
179+
_ = json.NewEncoder(w).Encode(agentapisdk.GetStatusResponse{
180+
Status: "stable",
181+
})
182+
},
183+
"/messages": func(w http.ResponseWriter, r *http.Request) {
184+
w.Header().Set("Content-Type", "application/json")
185+
mmu.RLock()
186+
defer mmu.RUnlock()
187+
bs, err := json.Marshal(agentapisdk.GetMessagesResponse{
188+
Messages: msgs,
189+
})
190+
if err != nil {
191+
writeAgentAPIError(w, err, http.StatusBadRequest)
192+
return
193+
}
194+
_, _ = w.Write(bs)
195+
},
196+
"/message": func(w http.ResponseWriter, r *http.Request) {
197+
mmu.Lock()
198+
defer mmu.Unlock()
199+
var params agentapisdk.PostMessageParams
200+
w.Header().Set("Content-Type", "application/json")
201+
err := json.NewDecoder(r.Body).Decode(&params)
202+
if !assert.NoError(t, err, "decode message") {
203+
writeAgentAPIError(w, err, http.StatusBadRequest)
204+
return
205+
}
206+
207+
if len(wantCpy) == 0 {
208+
assert.Fail(t, "unexpected message", "received message %v, but no more expected messages", params)
209+
writeAgentAPIError(w, xerrors.New("no more expected messages"), http.StatusBadRequest)
210+
return
211+
}
212+
exp := wantCpy[0]
213+
wantCpy = wantCpy[1:]
214+
215+
if !assert.Equal(t, exp, params.Content, "message content mismatch") {
216+
writeAgentAPIError(w, xerrors.New("unexpected message content: expected "+exp+", got "+params.Content), http.StatusBadRequest)
217+
return
218+
}
219+
220+
msgs = append(msgs, agentapisdk.Message{
221+
Id: int64(len(msgs) + 1),
222+
Content: params.Content,
223+
Role: agentapisdk.RoleUser,
224+
Time: time.Now().UTC(),
225+
})
226+
msgs = append(msgs, agentapisdk.Message{
227+
Id: int64(len(msgs) + 1),
228+
Content: params.Content,
229+
Role: agentapisdk.RoleAgent,
230+
Time: time.Now().UTC(),
231+
})
232+
assert.NoError(t, json.NewEncoder(w).Encode(agentapisdk.PostMessageResponse{
233+
Ok: true,
234+
}))
235+
},
236+
}
237+
}
238+
21239
// setupCLITaskTest creates a test workspace with an AI task template and agent,
22240
// with a fake agent API configured with the provided set of handlers.
23241
// Returns the user client and workspace.

0 commit comments

Comments
 (0)