Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions cmd/deploycreate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package cmd

import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/render-oss/cli/pkg/client"
"github.com/render-oss/cli/pkg/command"
"github.com/render-oss/cli/pkg/dependencies"
)

func TestDeployCreateJSONConfirmation(t *testing.T) {
for _, tty := range []bool{false, true} {
name := "non-TTY"
if tty {
name = "TTY"
}
t.Run(name, func(t *testing.T) {
for _, tt := range []struct {
name string
input string
confirm bool
wantEOF bool
}{
{name: "closed stdin", wantEOF: true},
{name: "confirmed prompt", input: "y\n"},
{name: "confirm flag", confirm: true},
} {
t.Run(tt.name, func(t *testing.T) {
// Use a focused server to observe exactly when the deploy POST occurs;
// the shared fake currently only supports listing deploys.
var requests []string
var deployBody client.CreateDeployJSONRequestBody
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.Method + " " + r.URL.Path {
case "GET /services/srv-test":
_, _ = io.WriteString(w, `{"id":"srv-test","name":"test-service"}`)
case "POST /services/srv-test/deploys":
if err := json.NewDecoder(r.Body).Decode(&deployBody); err != nil {
t.Errorf("invalid deploy body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"id":"dep-test","status":"queued"}`)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
t.Setenv("RENDER_CLI_CONFIG_PATH", newTestConfigPath(t))
t.Setenv("RENDER_HOST", server.URL)
t.Setenv("RENDER_API_KEY", "test-api-key")
c, err := client.NewClientWithResponses(server.URL)
require.NoError(t, err)
deps := dependencies.New(c)
deps.DetectRuntimeSignals = func() (command.RuntimeSignals, error) {
return command.RuntimeSignals{StdinTTY: tty, StdoutTTY: tty, StderrTTY: tty}, nil
}
root := newRootCmd()
create := &cobra.Command{Use: "create [serviceID]", RunE: deployCreateCmd.RunE}
create.Flags().String("commit", "", "")
create.Flags().String("image", "", "")
create.Flags().Bool("clear-cache", false, "")
create.Flags().Bool("wait", false, "")
deploys := &cobra.Command{Use: "deploys"}
deploys.AddCommand(create)
root.AddCommand(deploys)
setupRootCmdPersistentRun(root, deps)
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetIn(strings.NewReader(tt.input))
args := []string{"deploys", "create", "srv-test", "--commit", "0123abcd", "--output", "json"}
if tt.confirm {
args = append(args, "--confirm")
}
root.SetArgs(args)

err = root.Execute()
if tt.wantEOF {
require.ErrorIs(t, err, io.EOF)
assert.Contains(t, err.Error(), "--confirm")
assert.Empty(t, stdout.String())
assert.Equal(t, []string{"GET /services/srv-test"}, requests)
return
}
require.NoError(t, err)
assert.JSONEq(t, `{"id":"dep-test","status":"queued"}`, stdout.String())
assert.Equal(t, "POST /services/srv-test/deploys", requests[len(requests)-1])
require.NotNil(t, deployBody.CommitId)
assert.Equal(t, "0123abcd", *deployBody.CommitId)
if tt.confirm {
assert.Empty(t, stderr.String())
} else {
assert.Contains(t, stderr.String(), "Are you sure you want to deploy test-service?")
}
})
}
})
}
}
8 changes: 6 additions & 2 deletions pkg/command/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"

tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -54,18 +55,21 @@ func NonInteractiveWithConfirm[T any](cmd *cobra.Command, loadData LoadDataFunc[
if err != nil {
return false, err
}
_, err = cmd.OutOrStdout().Write([]byte(fmt.Sprintf("%s (y/n): ", message)))
_, err = cmd.ErrOrStderr().Write([]byte(fmt.Sprintf("%s (y/n): ", message)))
if err != nil {
return false, err
}

reader := bufio.NewReader(cmd.InOrStdin())
str, err := reader.ReadString('\n')
if errors.Is(err, io.EOF) {
return false, fmt.Errorf("confirmation required: rerun with --%s to skip confirmation prompts: %w", ConfirmFlag, err)
}
if err != nil {
return false, err
}
if str != "y\n" {
_, err := cmd.OutOrStdout().Write([]byte("Aborted\n"))
_, err := cmd.ErrOrStderr().Write([]byte("Aborted\n"))
return false, err
}
}
Expand Down
96 changes: 96 additions & 0 deletions pkg/command/wrapper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package command_test

import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"testing/iotest"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/render-oss/cli/pkg/command"
)

func TestNonInteractiveWithConfirm(t *testing.T) {
tests := []struct {
name string
input string
confirm bool
wantLoad bool
wantPrompt bool
wantAbort bool
wantEOF bool
}{
{name: "closed stdin requires explicit confirmation", wantPrompt: true, wantEOF: true},
{name: "unterminated input does not confirm", input: "y", wantPrompt: true, wantEOF: true},
{name: "accepted prompt preserves JSON stdout", input: "y\n", wantLoad: true, wantPrompt: true},
{name: "declined prompt preserves stdout", input: "n\n", wantPrompt: true, wantAbort: true},
{name: "confirm flag skips stdin", confirm: true, wantLoad: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := command.JSON
ctx := command.SetFormatInContext(context.Background(), &output)
ctx = command.SetConfirmInContext(ctx, tt.confirm)
cmd := &cobra.Command{}
cmd.SetContext(ctx)
cmd.SetIn(strings.NewReader(tt.input))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)

loaded := false
handled, err := command.NonInteractiveWithConfirm(cmd, func() (string, error) {
loaded = true
return "created", nil
}, func(data string) string { return data }, func() (string, error) {
return "Proceed?", nil
})

if tt.wantEOF {
require.ErrorIs(t, err, io.EOF)
assert.Contains(t, err.Error(), "--confirm")
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantLoad, loaded)
assert.Equal(t, tt.wantLoad, handled)
if tt.wantLoad {
assert.JSONEq(t, `"created"`, stdout.String())
} else {
assert.Empty(t, stdout.String())
}
if tt.wantPrompt {
assert.Contains(t, stderr.String(), "Proceed? (y/n): ")
} else {
assert.Empty(t, stderr.String())
}
assert.Equal(t, tt.wantAbort, strings.Contains(stderr.String(), "Aborted\n"))
})
}
}

func TestNonInteractiveWithConfirmReadError(t *testing.T) {
output := command.JSON
cmd := &cobra.Command{}
cmd.SetContext(command.SetFormatInContext(context.Background(), &output))
readErr := errors.New("read failed")
cmd.SetIn(iotest.ErrReader(readErr))
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)

_, err := command.NonInteractiveWithConfirm(cmd, func() (string, error) {
t.Fatal("action must not run when confirmation cannot be read")
return "", nil
}, func(data string) string { return data }, func() (string, error) { return "Proceed?", nil })

require.ErrorIs(t, err, readErr)
assert.Empty(t, stdout.String())
}