@@ -2,22 +2,240 @@ package cli_test
22
33import (
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