From c9cdd2e5ea17e3d86265403ca0e87cac073a6c1c Mon Sep 17 00:00:00 2001 From: Jeri Lane Date: Wed, 1 Jul 2026 09:56:20 -0700 Subject: [PATCH 1/3] Make CommandHarness accessible in non-integration tests --- temporalcloudcli/commands_test.go | 131 ------------------------------ temporalcloudcli/common_test.go | 130 +++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 131 deletions(-) diff --git a/temporalcloudcli/commands_test.go b/temporalcloudcli/commands_test.go index e798caa..71e9abc 100644 --- a/temporalcloudcli/commands_test.go +++ b/temporalcloudcli/commands_test.go @@ -4,17 +4,12 @@ package temporalcloudcli_test import ( - "bytes" "context" "errors" - "fmt" "io" "math/rand" "os" - "regexp" - "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,132 +22,6 @@ import ( "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" ) -type CommandHarness struct { - *require.Assertions - t *testing.T - Options temporalcloudcli.CommandOptions - // Defaults to a context closed on close or test complete - Context context.Context - // Can be used to cancel context given to commands (simulating interrupt) - CancelContext context.CancelFunc - Stdin bytes.Buffer -} - -func NewCommandHarness(t *testing.T) *CommandHarness { - h := &CommandHarness{Assertions: require.New(t), t: t} - h.Context, h.CancelContext = context.WithCancel(context.Background()) - t.Cleanup(h.Close) - return h -} - -// Reentrant, called after test by default, cancels context -func (h *CommandHarness) Close() { - // Cancel context - if h.CancelContext != nil { - h.CancelContext() - } -} - -// Pieces must appear in order on the line and not overlap -func (h *CommandHarness) ContainsOnSameLine(text string, pieces ...string) { - h.NoError(AssertContainsOnSameLine(text, pieces...)) -} - -func AssertContainsOnSameLine(text string, pieces ...string) error { - // Build regex pattern based on pieces - pattern := "" - for _, piece := range pieces { - if pattern != "" { - pattern += ".*" - } - pattern += regexp.QuoteMeta(piece) - } - regex, err := regexp.Compile(pattern) - if err != nil { - return err - } - // Split into lines, then check each piece is present - lines := strings.Split(text, "\n") - for _, line := range lines { - if regex.MatchString(line) { - return nil - } - } - return fmt.Errorf("pieces not found in order on any line together") -} - -func TestAssertContainsOnSameLine(t *testing.T) { - require.Error(t, AssertContainsOnSameLine("a b c", "b", "a")) - require.Error(t, AssertContainsOnSameLine("a\nb c", "a", "b")) - require.NoError(t, AssertContainsOnSameLine("aba", "b", "a")) - require.NoError(t, AssertContainsOnSameLine("a b a", "b", "a")) - require.NoError(t, AssertContainsOnSameLine("axb", "a", "b")) - require.NoError(t, AssertContainsOnSameLine("a a", "a", "a")) -} - -func (h *CommandHarness) Eventually( - condition func() bool, - waitFor time.Duration, - tick time.Duration, - msgAndArgs ...interface{}, -) { - h.t.Helper() - // We cannot use require.Eventually because it was poorly developed to run the - // condition function in a goroutine which means it can run after complete or - // have other race conditions. Don't even need a complicated ticker because it - // doesn't need to be interruptible. - for start := time.Now(); time.Since(start) < waitFor; { - if condition() { - return - } - time.Sleep(tick) - } - h.Fail("condition did not evaluate to true within timeout", msgAndArgs...) -} - -func (h *CommandHarness) T() *testing.T { - return h.t -} - -type CommandResult struct { - Err error - Stdout bytes.Buffer - Stderr bytes.Buffer -} - -func (h *CommandHarness) Execute(args ...string) *CommandResult { - // Copy options, update as needed - res := &CommandResult{} - options := h.Options - // Set stdio - options.Stdin = &h.Stdin - options.Stdout = &res.Stdout - options.Stderr = &res.Stderr - // Set args - options.Args = args - // Capture error - options.Fail = func(err error) { - if res.Err != nil { - panic("fail called twice, just failed with " + err.Error()) - } - res.Err = err - } - - // Run - ctx, cancel := context.WithCancel(h.Context) - h.t.Cleanup(cancel) - defer cancel() - h.t.Logf("Calling: %v", strings.Join(args, " ")) - temporalcloudcli.Execute(ctx, options) - if res.Stdout.Len() > 0 { - h.t.Logf("Stdout:\n-----\n%s\n-----", &res.Stdout) - } - if res.Stderr.Len() > 0 { - h.t.Logf("Stderr:\n-----\n%s\n-----", &res.Stderr) - } - return res -} - type EnvLookupMap map[string]string func (e EnvLookupMap) Environ() []string { diff --git a/temporalcloudcli/common_test.go b/temporalcloudcli/common_test.go index bca9f61..a4c9b56 100644 --- a/temporalcloudcli/common_test.go +++ b/temporalcloudcli/common_test.go @@ -5,7 +5,11 @@ import ( "context" "encoding/json" "errors" + "fmt" + "regexp" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,6 +21,132 @@ import ( "google.golang.org/grpc/status" ) +type CommandHarness struct { + *require.Assertions + t *testing.T + Options temporalcloudcli.CommandOptions + // Defaults to a context closed on close or test complete + Context context.Context + // Can be used to cancel context given to commands (simulating interrupt) + CancelContext context.CancelFunc + Stdin bytes.Buffer +} + +func NewCommandHarness(t *testing.T) *CommandHarness { + h := &CommandHarness{Assertions: require.New(t), t: t} + h.Context, h.CancelContext = context.WithCancel(context.Background()) + t.Cleanup(h.Close) + return h +} + +// Reentrant, called after test by default, cancels context +func (h *CommandHarness) Close() { + // Cancel context + if h.CancelContext != nil { + h.CancelContext() + } +} + +// Pieces must appear in order on the line and not overlap +func (h *CommandHarness) ContainsOnSameLine(text string, pieces ...string) { + h.NoError(AssertContainsOnSameLine(text, pieces...)) +} + +func AssertContainsOnSameLine(text string, pieces ...string) error { + // Build regex pattern based on pieces + pattern := "" + for _, piece := range pieces { + if pattern != "" { + pattern += ".*" + } + pattern += regexp.QuoteMeta(piece) + } + regex, err := regexp.Compile(pattern) + if err != nil { + return err + } + // Split into lines, then check each piece is present + lines := strings.Split(text, "\n") + for _, line := range lines { + if regex.MatchString(line) { + return nil + } + } + return fmt.Errorf("pieces not found in order on any line together") +} + +func TestAssertContainsOnSameLine(t *testing.T) { + require.Error(t, AssertContainsOnSameLine("a b c", "b", "a")) + require.Error(t, AssertContainsOnSameLine("a\nb c", "a", "b")) + require.NoError(t, AssertContainsOnSameLine("aba", "b", "a")) + require.NoError(t, AssertContainsOnSameLine("a b a", "b", "a")) + require.NoError(t, AssertContainsOnSameLine("axb", "a", "b")) + require.NoError(t, AssertContainsOnSameLine("a a", "a", "a")) +} + +func (h *CommandHarness) Eventually( + condition func() bool, + waitFor time.Duration, + tick time.Duration, + msgAndArgs ...interface{}, +) { + h.t.Helper() + // We cannot use require.Eventually because it was poorly developed to run the + // condition function in a goroutine which means it can run after complete or + // have other race conditions. Don't even need a complicated ticker because it + // doesn't need to be interruptible. + for start := time.Now(); time.Since(start) < waitFor; { + if condition() { + return + } + time.Sleep(tick) + } + h.Fail("condition did not evaluate to true within timeout", msgAndArgs...) +} + +func (h *CommandHarness) T() *testing.T { + return h.t +} + +type CommandResult struct { + Err error + Stdout bytes.Buffer + Stderr bytes.Buffer +} + +func (h *CommandHarness) Execute(args ...string) *CommandResult { + // Copy options, update as needed + res := &CommandResult{} + options := h.Options + // Set stdio + options.Stdin = &h.Stdin + options.Stdout = &res.Stdout + options.Stderr = &res.Stderr + // Set args + options.Args = args + // Capture error + options.Fail = func(err error) { + if res.Err != nil { + panic("fail called twice, just failed with " + err.Error()) + } + res.Err = err + } + + // Run + ctx, cancel := context.WithCancel(h.Context) + h.t.Cleanup(cancel) + defer cancel() + h.t.Logf("Calling: %v", strings.Join(args, " ")) + temporalcloudcli.Execute(ctx, options) + if res.Stdout.Len() > 0 { + h.t.Logf("Stdout:\n-----\n%s\n-----", &res.Stdout) + } + if res.Stderr.Len() > 0 { + h.t.Logf("Stderr:\n-----\n%s\n-----", &res.Stderr) + } + return res +} + func newTestCommandContext(t *testing.T, buf *bytes.Buffer) *temporalcloudcli.CommandContext { t.Helper() return &temporalcloudcli.CommandContext{ From d81063d1b6f3d08a775ad236725167a110c144f7 Mon Sep 17 00:00:00 2001 From: Jeri Lane Date: Wed, 1 Jul 2026 10:06:36 -0700 Subject: [PATCH 2/3] Ensure errors don't cause silent failure, make authN failure messages friendlier --- temporalcloudcli/cloud.go | 25 ++++-- temporalcloudcli/commands.go | 41 ++++++++- temporalcloudcli/commands.whoami.go | 4 +- temporalcloudcli/commands.whoami_unit_test.go | 49 +++++++++++ temporalcloudcli/commands_unit_test.go | 83 +++++++++++++++++++ temporalcloudcli/common.go | 70 ++++++++++++++++ temporalcloudcli/common_test.go | 51 ++++++++++++ 7 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 temporalcloudcli/commands.whoami_unit_test.go create mode 100644 temporalcloudcli/commands_unit_test.go diff --git a/temporalcloudcli/cloud.go b/temporalcloudcli/cloud.go index bcfb8ce..833eea2 100644 --- a/temporalcloudcli/cloud.go +++ b/temporalcloudcli/cloud.go @@ -74,6 +74,21 @@ func (b *CloudOptionsBuilder) Build(ctx context.Context) (*CloudOptions, error) return cloudOpts, nil } +type ( + errSlot struct{ err error } + errSlotKey struct{} +) + +// errorContext will add err to the errSlot on ctx if it has one before returning v and err +func errorContext[T any](ctx context.Context, v T, err error) (T, error) { + if ctx != nil { + if slot, ok := ctx.Value(errSlotKey{}).(*errSlot); ok { + slot.err = err + } + } + return v, err +} + func (c *CloudOptions) GetAPIKey(ctx context.Context) (string, error) { loadClientOauthRes, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{ ConfigFilePath: c.ConfigFile, @@ -81,20 +96,20 @@ func (c *CloudOptions) GetAPIKey(ctx context.Context) (string, error) { EnvLookup: envconfig.EnvLookupOS, }) if err != nil { - return "", fmt.Errorf("failed to load login configuration: %w, please run `temporal cloud login --reset`", err) + return errorContext(ctx, "", NewFriendlyError("failed to load login configuration, please run `temporal cloud login --reset`", err)) } // check if we have had a valid token in the past if loadClientOauthRes.OAuth == nil || loadClientOauthRes.OAuth.ClientConfig == nil { - return "", fmt.Errorf("no login session found, please run `temporal cloud login`") + return errorContext(ctx, "", NewFriendlyError("no login session found, please run `temporal cloud login`", nil)) } token, refreshed, err := GetToken(ctx, loadClientOauthRes.OAuth.ClientConfig, loadClientOauthRes.OAuth.Token) if err != nil { if errors.Is(err, ErrLoginRequired) { - return "", fmt.Errorf("login session expired, please run `temporal cloud login`: %w", err) + return errorContext(ctx, "", NewFriendlyError("login session expired, please run `temporal cloud login`", err)) } - return "", fmt.Errorf("failed to get access token: %w", err) + return errorContext(ctx, "", NewFriendlyErrorf("failed to get access token: %v", err)) } if refreshed { loadClientOauthRes.OAuth.Token = token @@ -104,7 +119,7 @@ func (c *CloudOptions) GetAPIKey(ctx context.Context) (string, error) { ProfileName: c.Profile, EnvLookup: envconfig.EnvLookupOS, }); err != nil { - return "", fmt.Errorf("failed to write config file: %w", err) + return errorContext(ctx, "", NewFriendlyErrorf("failed to write config file: %v", err)) } } return token.AccessToken, nil diff --git a/temporalcloudcli/commands.go b/temporalcloudcli/commands.go index 1ce73d8..6286006 100644 --- a/temporalcloudcli/commands.go +++ b/temporalcloudcli/commands.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -185,6 +186,7 @@ func (cctx *CommandContext) BuildCloudClient(clientOpts ClientOptions) (*cloudcl opts.GRPCDialOptions = []grpc.DialOption{ grpc.WithChainUnaryInterceptor( + grabOriginalErrorInterceptor, ClearDeprecatedFieldsInterceptor, ), } @@ -196,6 +198,28 @@ func (cctx *CommandContext) BuildCloudClient(clientOpts ClientOptions) (*cloudcl return cloudClient, nil } +// grabOriginalErrorInterceptor is a gRPC unary client interceptor that adds +// a "slot" for error storage to the context passed to invoker. If an error occurs +// and a downstream function has stored an error in errSlot, the two will be grafted +// together. This allows for upstream error handling to perform checks with errors.Is +// or errors.As, since grpc itself doesn't wrap errors. +func grabOriginalErrorInterceptor( + ctx context.Context, + method string, + req, reply any, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + opts ...grpc.CallOption, +) error { + slot := &errSlot{} + ctx = context.WithValue(ctx, errSlotKey{}, slot) + err := invoker(ctx, method, req, reply, cc, opts...) + if err != nil { + return GraftErrors(err, slot.err) + } + return nil +} + // clearDeprecatedFieldsInterceptor is a gRPC unary client interceptor that strips // deprecated fields from every response. Any proto field whose name ends with // "_deprecated" or that is marked with [deprecated = true] is cleared; nested @@ -244,10 +268,23 @@ func (c *CommandContext) preprocessOptions() error { if c.Err() != nil { err = fmt.Errorf("program interrupted") } + if c.Logger != nil { c.Logger.Error(err.Error()) - } else { - fmt.Fprintln(os.Stderr, err) + } + + // FriendlyErrors are intended for presentation directly to the user without requiring the full error context + // If we got one, we can print it and exit + if friendlyError, ok := errors.AsType[FriendlyError](err); ok { + fmt.Fprintln(c.Options.Stderr, friendlyError.FriendlyError()) + os.Exit(1) + } + + // We weren't able to obtain a simple message to print, so we make sure the full error context gets sent + // *somewhere*. If we don't have a logger or it's not sending messages anywhere (the default noop logger), + // then we print the full error to stderr to ensure we don't just fail silently. + if c.Logger == nil || !c.Logger.Enabled(nil, slog.LevelError) { + fmt.Fprintln(c.Options.Stderr, err) } os.Exit(1) } diff --git a/temporalcloudcli/commands.whoami.go b/temporalcloudcli/commands.whoami.go index 0e15557..8d8e91b 100644 --- a/temporalcloudcli/commands.whoami.go +++ b/temporalcloudcli/commands.whoami.go @@ -9,12 +9,12 @@ import ( // run calls GetCurrentIdentity and prints the authenticated principal (User or // ServiceAccount) along with the associated API key, if any. func (c *CloudWhoamiCommand) run(cctx *CommandContext, _ []string) error { - cloudClient, err := cctx.BuildCloudClient(c.ClientOptions) + cloudClient, err := cctx.GetCloudClient(c.ClientOptions) if err != nil { return err } - res, err := cloudClient.CloudService().GetCurrentIdentity(cctx.Context, &cloudservice.GetCurrentIdentityRequest{}) + res, err := cloudClient.GetCurrentIdentity(cctx.Context, &cloudservice.GetCurrentIdentityRequest{}) if err != nil { return err } diff --git a/temporalcloudcli/commands.whoami_unit_test.go b/temporalcloudcli/commands.whoami_unit_test.go new file mode 100644 index 0000000..1dd2678 --- /dev/null +++ b/temporalcloudcli/commands.whoami_unit_test.go @@ -0,0 +1,49 @@ +package temporalcloudcli_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/mock" + cloudmock "github.com/temporalio/cloud-cli/internal/cloudservice/mock" + "github.com/temporalio/cloud-cli/temporalcloudcli" + cloudservice "go.temporal.io/cloud-sdk/api/cloudservice/v1" +) + +func TestWhoami(t *testing.T) { + tests := []struct { + name string + cmd temporalcloudcli.CloudWhoamiCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + expectedErr string + }{ + { + name: "WhoamiSuccess", + cmd: temporalcloudcli.CloudWhoamiCommand{}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetCurrentIdentity(mock.Anything, &cloudservice.GetCurrentIdentityRequest{}, mock.Anything). + Return(&cloudservice.GetCurrentIdentityResponse{}, nil) + }, + }, + { + name: "WhoamiNotLoggedIn", + cmd: temporalcloudcli.CloudWhoamiCommand{}, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetCurrentIdentity(mock.Anything, &cloudservice.GetCurrentIdentityRequest{}, mock.Anything). + Return(nil, fmt.Errorf("no login session found, please run `temporal cloud login`")) + }, + expectedErr: "no login session found, please run `temporal cloud login`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + ExpectedError: tt.expectedErr, + }) + }) + } +} diff --git a/temporalcloudcli/commands_unit_test.go b/temporalcloudcli/commands_unit_test.go new file mode 100644 index 0000000..27f346a --- /dev/null +++ b/temporalcloudcli/commands_unit_test.go @@ -0,0 +1,83 @@ +package temporalcloudcli_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/temporalio/cloud-cli/temporalcloudcli" +) + +// ExecuteAsSubprocess is primarily usedful for testing default Fail() behavior of commands, +// since Excecute sets its own Fail handler. +func (h *CommandHarness) ExecuteAsSubprocess(args ...string) *CommandResult { + res := &CommandResult{} + testName := h.t.Name() + if os.Getenv("GO_TEST_FUNC") == testName { + options := h.Options + options.Args = args + // Run + ctx, cancel := context.WithCancel(h.Context) + h.t.Cleanup(cancel) + defer cancel() + h.t.Logf("Calling: %v\n", strings.Join(args, " ")) + temporalcloudcli.Execute(ctx, options) + return nil + } + + cmd := exec.Command(os.Args[0], fmt.Sprintf("-test.run=^%s$", testName)) + cmd.Env = append(os.Environ(), fmt.Sprintf("GO_TEST_FUNC=%s", testName)) + + stdoutPipe, err := cmd.StdoutPipe() + assert.NoErrorf(h.t, err, "Could not set up stdout") + stderrPipe, err := cmd.StderrPipe() + assert.NoErrorf(h.t, err, "Could not set up stderr") + + err = cmd.Start() + assert.NoErrorf(h.t, err, "Could not start subcommand") + + _, err = res.Stdout.ReadFrom(stdoutPipe) + assert.NoErrorf(h.t, err, "Failed reading stdout from subcommand") + _, err = res.Stderr.ReadFrom(stderrPipe) + assert.NoErrorf(h.t, err, "Failed reading stderr from subcommand") + + res.Err = cmd.Wait() + return res +} + +func TestUnknownCommandExitsNonzero(t *testing.T) { + commandHarness := NewCommandHarness(t) + res := commandHarness.Execute("blerkflow") + assert.Contains(t, res.Err.Error(), "unknown command") +} + +func TestErrorsAreNotLogMessages(t *testing.T) { + commandHarness := NewCommandHarness(t) + t.Setenv("TEMPORAL_API_KEY", "") + res := commandHarness.ExecuteAsSubprocess("whoami") + + require.Error(t, res.Err) + stdout := res.Stdout.String() + stderr := res.Stderr.String() + assert.NotContains(t, stdout, "level=") + assert.NotContains(t, stderr, "level=") + assert.Contains(t, stderr, "no login session found, please run `temporal cloud login`") +} + +func TestErrorsAreNotLogMessagesJSON(t *testing.T) { + commandHarness := NewCommandHarness(t) + t.Setenv("TEMPORAL_API_KEY", "") + res := commandHarness.ExecuteAsSubprocess("whoami", "--output", "json") + + require.Error(t, res.Err) + stdout := res.Stdout.String() + stderr := res.Stderr.String() + assert.NotContains(t, stdout, "level=") + assert.NotContains(t, stderr, "level=") + assert.Contains(t, stderr, "no login session found, please run `temporal cloud login`") +} diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go index da11caa..d98109d 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -19,6 +19,76 @@ import ( "github.com/temporalio/cloud-cli/temporalcloudcli/internal/printer" ) +// A FriendlyError is an error intended for presentation directly to users +type FriendlyError struct { + reason string + cause error + embedsCause bool +} + +func (err FriendlyError) Error() string { + if err.cause != nil && !err.embedsCause { + return fmt.Sprintf("%s: %v", err.reason, err.cause) + } else { + return err.reason + } +} + +func (err FriendlyError) Unwrap() error { + return err.cause +} + +func (err FriendlyError) FriendlyError() string { + return err.reason +} + +// NewFriendlyError returns an error that preserves the given reason for later presentation +// as a user-facing error message, available via FriendlyError(). Full context is available +// via Error() and Unwrap() +func NewFriendlyError(reason string, cause error) FriendlyError { + return FriendlyError{reason, cause, false} +} + +func NewFriendlyErrorf(format string, args ...any) FriendlyError { + reason := fmt.Sprintf(format, args...) + var cause error + for _, arg := range args { + if err, ok := arg.(error); ok { + cause = err + break + } + } + embedsCause := cause != nil + return FriendlyError{reason, cause, embedsCause} +} + +type GraftedError struct { + primary error + grafted error +} + +func (err GraftedError) Error() string { + return err.primary.Error() +} + +func (err GraftedError) Unwrap() []error { + // including primary as well preserves compatibility with things like status.Code + return []error{err.primary, err.grafted} +} + +// GraftErrors encapsulates err and wraps toWrap, as if err had wrapped toWrap originally. +// This is primarily useful when an external library does not wrap errors itself, but the +// original error is available via other means +func GraftErrors(err error, toWrap error) error { + if err == nil { + return toWrap + } + if toWrap == nil { + return err + } + return GraftedError{err, toWrap} +} + func isNothingChangedErr(idempotent bool, e error) bool { // If we are not idempotent, we should error on nothing to change if !idempotent { diff --git a/temporalcloudcli/common_test.go b/temporalcloudcli/common_test.go index a4c9b56..fe39da4 100644 --- a/temporalcloudcli/common_test.go +++ b/temporalcloudcli/common_test.go @@ -417,3 +417,54 @@ func TestParseServiceAccountEmail(t *testing.T) { }) } } + +func TestFriendlyError(t *testing.T) { + originalErr := fmt.Errorf("details") + err := temporalcloudcli.NewFriendlyError("a simple error", originalErr) + formattedErr := temporalcloudcli.NewFriendlyErrorf("An error with %v inline", originalErr) + + t.Run("FriendlyErrorFormatting", func(t *testing.T) { + assert.Equal(t, "a simple error", err.FriendlyError()) + assert.Equal(t, "An error with details inline", formattedErr.FriendlyError()) + }) + + t.Run("ErrorFormatting", func(t *testing.T) { + assert.Equal(t, "a simple error: details", err.Error()) + assert.Equal(t, "An error with details inline", formattedErr.Error()) + }) + + t.Run("IsOriginalError", func(t *testing.T) { + assert.ErrorIs(t, err, originalErr) + assert.ErrorIs(t, formattedErr, originalErr) + }) +} + +func TestErrorGrafting(t *testing.T) { + originalErr := fmt.Errorf("some complex error message") + friendlyErr := temporalcloudcli.NewFriendlyError("busted", originalErr) + gRPCErr := status.Errorf(codes.Internal, "something bad happened: %v", friendlyErr) + graftedErr := temporalcloudcli.GraftErrors(gRPCErr, friendlyErr) + + t.Run("DoesNotModifyErrorMessage", func(t *testing.T) { + assert.Equal(t, gRPCErr.Error(), graftedErr.Error()) + assert.NotRegexp(t, "busted.*busted", graftedErr.Error(), "should not duplicate the message in the grafted error") + }) + + t.Run("LooksLikeGRPCError", func(t *testing.T) { + assert.Equal(t, codes.Internal, status.Code(graftedErr)) + s, ok := status.FromError(graftedErr) + assert.True(t, ok) + assert.Equal(t, codes.Internal, s.Code()) + }) + + t.Run("IsBothErrors", func(t *testing.T) { + assert.ErrorIs(t, graftedErr, gRPCErr) + assert.ErrorIs(t, graftedErr, friendlyErr) + }) + + t.Run("AsKnownErrorType", func(t *testing.T) { + var target temporalcloudcli.FriendlyError + assert.ErrorAs(t, graftedErr, &target) + assert.Equal(t, friendlyErr.FriendlyError(), target.FriendlyError()) + }) +} From dde51cad88aa57eb9493fa73cdb01a9d1ae82947 Mon Sep 17 00:00:00 2001 From: Jeri Lane Date: Mon, 6 Jul 2026 09:11:35 -0700 Subject: [PATCH 3/3] Update go version in line with temporalio/cli --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index bb5c9bb..2e32c2d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/temporalio/cloud-cli -go 1.26.3 +go 1.26.4 require ( github.com/aws/aws-sdk-go-v2 v1.41.11