From 252f430eb93b2f7c7dad7b62bc663e92a75977c8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 25 Sep 2025 15:40:02 +0100 Subject: [PATCH] feat(cli): add exp task send (#19922) Closes https://github.com/coder/internal/issues/895 --------- Co-authored-by: Mathias Fredriksson --- cli/exp_task.go | 1 + cli/exp_task_send.go | 78 +++++++++++++++++ cli/exp_task_send_test.go | 173 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 cli/exp_task_send.go create mode 100644 cli/exp_task_send_test.go diff --git a/cli/exp_task.go b/cli/exp_task.go index 9d4f1d814f..5c93f3247b 100644 --- a/cli/exp_task.go +++ b/cli/exp_task.go @@ -17,6 +17,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command { r.taskCreate(), r.taskStatus(), r.taskDelete(), + r.taskSend(), }, } return cmd diff --git a/cli/exp_task_send.go b/cli/exp_task_send.go new file mode 100644 index 0000000000..c30c3597db --- /dev/null +++ b/cli/exp_task_send.go @@ -0,0 +1,78 @@ +package cli + +import ( + "io" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) taskSend() *serpent.Command { + var stdin bool + + cmd := &serpent.Command{ + Use: "send [ | --stdin]", + Short: "Send input to a task", + Middleware: serpent.RequireRangeArgs(1, 2), + Options: serpent.OptionSet{ + { + Name: "stdin", + Flag: "stdin", + Description: "Reads the input from stdin.", + Value: serpent.BoolOf(&stdin), + }, + }, + Handler: func(inv *serpent.Invocation) error { + client, err := r.InitClient(inv) + if err != nil { + return err + } + + var ( + ctx = inv.Context() + exp = codersdk.NewExperimentalClient(client) + task = inv.Args[0] + + taskInput string + taskID uuid.UUID + ) + + if stdin { + bytes, err := io.ReadAll(inv.Stdin) + if err != nil { + return xerrors.Errorf("reading stdio: %w", err) + } + + taskInput = string(bytes) + } else { + if len(inv.Args) != 2 { + return xerrors.Errorf("expected an input for the task") + } + + taskInput = inv.Args[1] + } + + if id, err := uuid.Parse(task); err == nil { + taskID = id + } else { + ws, err := namedWorkspace(ctx, client, task) + if err != nil { + return xerrors.Errorf("resolve task: %w", err) + } + + taskID = ws.ID + } + + if err = exp.TaskSend(ctx, codersdk.Me, taskID, codersdk.TaskSendRequest{Input: taskInput}); err != nil { + return xerrors.Errorf("send input to task: %w", err) + } + + return nil + }, + } + + return cmd +} diff --git a/cli/exp_task_send_test.go b/cli/exp_task_send_test.go new file mode 100644 index 0000000000..1d7b863f5f --- /dev/null +++ b/cli/exp_task_send_test.go @@ -0,0 +1,173 @@ +package cli_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func Test_TaskSend(t *testing.T) { + t.Parallel() + + var ( + taskName = "task-workspace" + taskID = uuid.MustParse("11111111-1111-1111-1111-111111111111") + ) + + tests := []struct { + args []string + stdin string + expectError string + handler func(t *testing.T, ctx context.Context) http.HandlerFunc + }{ + { + args: []string{taskName, "carry on with the task"}, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName): + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: taskID, + }) + case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()): + var req codersdk.TaskSendRequest + if !httpapi.Read(ctx, w, r, &req) { + return + } + + assert.Equal(t, "carry on with the task", req.Input) + + httpapi.Write(ctx, w, http.StatusNoContent, nil) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{taskID.String(), "carry on with the task"}, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()): + var req codersdk.TaskSendRequest + if !httpapi.Read(ctx, w, r, &req) { + return + } + + assert.Equal(t, "carry on with the task", req.Input) + + httpapi.Write(ctx, w, http.StatusNoContent, nil) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{taskName, "--stdin"}, + stdin: "carry on with the task", + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/v2/users/me/workspace/%s", taskName): + httpapi.Write(ctx, w, http.StatusOK, codersdk.Workspace{ + ID: taskID, + }) + case fmt.Sprintf("/api/experimental/tasks/me/%s/send", taskID.String()): + var req codersdk.TaskSendRequest + if !httpapi.Read(ctx, w, r, &req) { + return + } + + assert.Equal(t, "carry on with the task", req.Input) + + httpapi.Write(ctx, w, http.StatusNoContent, nil) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{"doesnotexist", "some task input"}, + expectError: httpapi.ResourceNotFoundResponse.Message, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/users/me/workspace/doesnotexist": + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{uuid.Nil.String(), "some task input"}, + expectError: httpapi.ResourceNotFoundResponse.Message, + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/experimental/tasks/me/%s/send", uuid.Nil.String()): + httpapi.ResourceNotFound(w) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + { + args: []string{uuid.Nil.String(), "some task input"}, + expectError: assert.AnError.Error(), + handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/experimental/tasks/me/%s/send", uuid.Nil.String()): + httpapi.InternalServerError(w, assert.AnError) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + } + }, + }, + } + + for _, tt := range tests { + t.Run(strings.Join(tt.args, ","), func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + srv = httptest.NewServer(tt.handler(t, ctx)) + client = codersdk.New(testutil.MustURL(t, srv.URL)) + args = []string{"exp", "task", "send"} + err error + ) + + t.Cleanup(srv.Close) + + inv, root := clitest.New(t, append(args, tt.args...)...) + inv.Stdin = strings.NewReader(tt.stdin) + clitest.SetupConfig(t, client, root) + + err = inv.WithContext(ctx).Run() + if tt.expectError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.expectError) + } + }) + } +}