diff --git a/internal/locale/locales/en-us.yaml b/internal/locale/locales/en-us.yaml index f111157424..d409e30474 100644 --- a/internal/locale/locales/en-us.yaml +++ b/internal/locale/locales/en-us.yaml @@ -1761,8 +1761,8 @@ operation_success_local: other: | Your local project has been updated. Run [ACTIONABLE]state push[/RESET] to save changes to the platform. -solver_err: - other: "The Platform failed to resolve the dependencies for this build. {{.V0}}\n{{.V1}}" +buildplan_err: + other: "Could not plan build, platform responded with:\n{{.V0}}\n{{.V1}}" transient_solver_tip: other: You may want to retry this command if the failure was related to a resourcing or networking issue. warning_git_project_mismatch: @@ -2059,7 +2059,7 @@ err_init_authenticated: err_country_blocked: other: This service is not available in your region. err_commit_id_invalid: - other: "Invalid project commit ID: {{.V0}}" + other: "Expected commit ID to be UUID formatted, but got: {{.V0}}" commit_id_gitignore: other: | # Ignore {{.V0}}/{{.V1}} file; tracking this via VCS will cause users to have to resolve redundant conflicts @@ -2083,3 +2083,15 @@ err_edit_project_mapping: other: Could not update project mapping notice_runtime_disabled: other: Skipping runtime setup because it was disabled by an environment variable +err_local_commit_file: + other: | + Could not find or read the commit file for your project at '[ACTIONABLE]{{.V0}}[/RESET]'. + + You can fix this by either running '[ACTIONABLE]state pull[/RESET]' to update to the latest commit, or manually switch to a specific + commit by running '[ACTIONABLE]state switch [/RESET]'. + + To view your projects commits run '[ACTIONABLE]state history[/RESET]'. + + Please avoid deleting or editing this file directly. +err_searchingredient_toomany: + other: Too many ingredients match the query '[ACTIONABLE]{{.V0}}[/RESET]', please try to be more specific. diff --git a/internal/runbits/rationalize/types.go b/internal/runbits/rationalize/types.go index 13d54f2464..264794e7fa 100644 --- a/internal/runbits/rationalize/types.go +++ b/internal/runbits/rationalize/types.go @@ -1,6 +1,9 @@ package rationalize -import "errors" +import ( + "errors" + "fmt" +) // Inner is just an alias because otherwise external use of this struct would not be able to construct the error // property, and we want to keep the boilerplate minimal. @@ -13,3 +16,13 @@ var ErrNoProject = errors.New("no project") var ErrNotAuthenticated = errors.New("not authenticated") var ErrActionAborted = errors.New("aborted by user") + +type ErrAPI struct { + Wrapped error + Code int + Message string +} + +func (e *ErrAPI) Error() string { return fmt.Sprintf("API code %d: %s", e.Code, e.Message) } + +func (e *ErrAPI) Unwrap() error { return e.Wrapped } diff --git a/internal/runbits/requirements/rationalize.go b/internal/runbits/requirements/rationalize.go new file mode 100644 index 0000000000..adade766d9 --- /dev/null +++ b/internal/runbits/requirements/rationalize.go @@ -0,0 +1,54 @@ +package requirements + +import ( + "errors" + + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/locale" + "github.com/ActiveState/cli/pkg/localcommit" + bpModel "github.com/ActiveState/cli/pkg/platform/api/buildplanner/model" + "github.com/ActiveState/cli/pkg/platform/model" +) + +func (r *RequirementOperation) rationalizeError(err *error) { + var localCommitFileErr *localcommit.ErrLocalCommitFile + var tooManyMatchesErr *model.ErrTooManyMatches + var noMatchesErr *ErrNoMatches + var buildPlannerErr *bpModel.BuildPlannerError + + switch { + case err == nil: + return + + // Local commit file not found or malformed + case errors.As(*err, &localCommitFileErr): + *err = errs.WrapUserFacing(*err, + locale.Tr("err_local_commit_file", localCommitFileErr.File), + errs.SetInput()) + + // Too many matches + case errors.As(*err, &tooManyMatchesErr): + *err = errs.WrapUserFacing(*err, + locale.Tr("err_searchingredient_toomany", tooManyMatchesErr.Query), + errs.SetInput()) + + // No matches, and no alternate suggestions + case errors.As(*err, &noMatchesErr) && noMatchesErr.Alternatives == nil: + *err = errs.WrapUserFacing(*err, + locale.Tr("package_ingredient_alternatives_nosuggest", noMatchesErr.Query), + errs.SetInput()) + + // No matches, but have alternate suggestions + case errors.As(*err, &noMatchesErr) && noMatchesErr.Alternatives != nil: + *err = errs.WrapUserFacing(*err, + locale.Tr("package_ingredient_alternatives", noMatchesErr.Query, *noMatchesErr.Alternatives), + errs.SetInput()) + + // We communicate buildplanner errors verbatim as the intend is that these are curated by the buildplanner + case errors.As(*err, &buildPlannerErr): + *err = errs.WrapUserFacing(*err, + buildPlannerErr.LocalizedError(), + errs.SetIf(buildPlannerErr.InputError(), errs.SetInput())) + + } +} diff --git a/internal/runbits/requirements/requirements.go b/internal/runbits/requirements/requirements.go index 0e368828b2..9c477f32a6 100644 --- a/internal/runbits/requirements/requirements.go +++ b/internal/runbits/requirements/requirements.go @@ -19,6 +19,7 @@ import ( "github.com/ActiveState/cli/internal/output" "github.com/ActiveState/cli/internal/primer" "github.com/ActiveState/cli/internal/prompt" + "github.com/ActiveState/cli/internal/rtutils/ptr" "github.com/ActiveState/cli/internal/runbits" "github.com/ActiveState/cli/internal/runbits/rtusage" "github.com/ActiveState/cli/pkg/localcommit" @@ -80,7 +81,16 @@ func NewRequirementOperation(prime primeable) *RequirementOperation { const latestVersion = "latest" -func (r *RequirementOperation) ExecuteRequirementOperation(requirementName, requirementVersion string, requirementBitWidth int, operation bpModel.Operation, nsType model.NamespaceType) (rerr error) { +type ErrNoMatches struct { + *locale.LocalizedError + Query string + Alternatives *string +} + +func (r *RequirementOperation) ExecuteRequirementOperation(requirementName, requirementVersion string, + requirementBitWidth int, operation bpModel.Operation, nsType model.NamespaceType) (rerr error) { + defer r.rationalizeError(&rerr) + var ns model.Namespace var langVersion string langName := "undetermined" @@ -182,9 +192,13 @@ func (r *RequirementOperation) ExecuteRequirementOperation(requirementName, requ multilog.Error("Failed to retrieve suggestions: %v", err) } if len(suggestions) == 0 { - return locale.WrapInputError(err, "package_ingredient_alternatives_nosuggest", "", requirementName) + return &ErrNoMatches{ + locale.WrapInputError(err, "package_ingredient_alternatives_nosuggest", "", requirementName), + requirementName, nil} } - return locale.WrapInputError(err, "package_ingredient_alternatives", "", requirementName, strings.Join(suggestions, "\n")) + return &ErrNoMatches{ + locale.WrapInputError(err, "package_ingredient_alternatives", "", requirementName, strings.Join(suggestions, "\n")), + requirementName, ptr.To(strings.Join(suggestions, "\n"))} } requirementName = normalized diff --git a/internal/testhelpers/tagsuite/tagsuite.go b/internal/testhelpers/tagsuite/tagsuite.go index d80e7cf0a8..5bd97c8838 100644 --- a/internal/testhelpers/tagsuite/tagsuite.go +++ b/internal/testhelpers/tagsuite/tagsuite.go @@ -38,6 +38,7 @@ const ( Init = "init" InstallScripts = "install-scripts" Installer = "installer" + Install = "install" Invite = "invite" RemoteInstaller = "remote-installer" Interrupt = "interrupt" diff --git a/pkg/localcommit/localcommit.go b/pkg/localcommit/localcommit.go index 06121f6f61..8a8f62deda 100644 --- a/pkg/localcommit/localcommit.go +++ b/pkg/localcommit/localcommit.go @@ -2,20 +2,33 @@ package localcommit import ( "bytes" + "errors" "fmt" "path/filepath" "github.com/ActiveState/cli/internal/constants" - "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/fileutils" "github.com/ActiveState/cli/internal/locale" "github.com/go-openapi/strfmt" ) -type FileDoesNotExistError struct{ *locale.LocalizedError } +type ErrLocalCommitFile struct { + *locale.LocalizedError // for backwards compatibility with runners that don't implement rationalizers + errorMsg string + IsDoesNotExist bool + File string +} + +func (e *ErrLocalCommitFile) Error() string { + return e.errorMsg +} func IsFileDoesNotExistError(err error) bool { - return errs.Matches(err, &FileDoesNotExistError{}) + var errLocalCommit *ErrLocalCommitFile + if errors.As(err, &errLocalCommit) { + return errLocalCommit.IsDoesNotExist + } + return false } func getCommitFile(projectDir string) string { @@ -26,18 +39,26 @@ func Get(projectDir string) (strfmt.UUID, error) { configDir := filepath.Join(projectDir, constants.ProjectConfigDirName) commitFile := getCommitFile(projectDir) if !fileutils.DirExists(configDir) || !fileutils.TargetExists(commitFile) { - return "", &FileDoesNotExistError{locale.NewError("err_commit_file_does_not_exist", - "Your project runtime's commit ID file '{{.V0}}' does not exist", commitFile)} + return "", &ErrLocalCommitFile{ + locale.NewError("err_local_commit_file", commitFile), + "local commit file does not exist", + true, commitFile} } b, err := fileutils.ReadFile(commitFile) if err != nil { - return "", locale.WrapError(err, "err_get_commit_file", "Could not read your project runtime's commit ID file") + return "", &ErrLocalCommitFile{ + locale.NewError("err_local_commit_file", commitFile), + "local commit could not be read", + false, commitFile} } commitID := string(b) if !strfmt.IsUUID(commitID) { - return "", locale.NewError("err_commit_id_invalid", commitID) + return "", &ErrLocalCommitFile{ + locale.NewError("err_local_commit_file", commitFile), + "local commit is not uuid formatted", + false, commitFile} } return strfmt.UUID(commitID), nil diff --git a/pkg/platform/api/api.go b/pkg/platform/api/api.go index c478bb66a7..f0605cce83 100644 --- a/pkg/platform/api/api.go +++ b/pkg/platform/api/api.go @@ -8,6 +8,7 @@ import ( "reflect" "strings" + "github.com/ActiveState/cli/internal/runbits/rationalize" "github.com/alecthomas/template" "github.com/ActiveState/cli/pkg/sysinfo" @@ -144,3 +145,18 @@ func ErrorMessageFromPayload(err error) string { } return codeVal.String() } + +func ErrorFromPayload(err error) error { + return ErrorFromPayloadTyped(err) +} + +func ErrorFromPayloadTyped(err error) *rationalize.ErrAPI { + if err == nil { + return nil + } + return &rationalize.ErrAPI{ + err, + ErrorCodeFromPayload(err), + ErrorMessageFromPayload(err), + } +} diff --git a/pkg/platform/api/buildplanner/model/buildplan.go b/pkg/platform/api/buildplanner/model/buildplan.go index ef65076a09..f5c8bea925 100644 --- a/pkg/platform/api/buildplanner/model/buildplan.go +++ b/pkg/platform/api/buildplanner/model/buildplan.go @@ -151,7 +151,7 @@ func (e *BuildPlannerError) Error() string { } var err error - err = locale.NewError("solver_err", "", croppedMessage, errorLines) + err = locale.NewError("buildplan_err", "", croppedMessage, errorLines) if e.IsTransient { err = errs.AddTips(err, locale.Tr("transient_solver_tip")) } diff --git a/pkg/platform/model/inventory.go b/pkg/platform/model/inventory.go index 828a39b777..56182a5601 100644 --- a/pkg/platform/model/inventory.go +++ b/pkg/platform/model/inventory.go @@ -100,6 +100,11 @@ func FetchAuthors(ingredID, ingredVersionID *strfmt.UUID) (Authors, error) { return results.Payload.Authors, nil } +type ErrTooManyMatches struct { + *locale.LocalizedError + Query string +} + func searchIngredientsNamespace(ns Namespace, name string, includeVersions bool, exactOnly bool) ([]*IngredientAndVersion, error) { limit := int64(100) offset := int64(0) @@ -123,7 +128,7 @@ func searchIngredientsNamespace(ns Namespace, name string, includeVersions bool, for offset == 0 || len(entries) == int(limit) { if offset > (limit * 10) { // at most we will get 10 pages of ingredients (that's ONE THOUSAND ingredients) // Guard against queries that match TOO MANY ingredients - return nil, locale.NewError("err_searchingredient_toomany", "Query matched too many ingredients. Please use a more specific query.") + return nil, &ErrTooManyMatches{locale.NewInputError("err_searchingredient_toomany", "", name), name} } params.SetOffset(&offset) diff --git a/pkg/platform/model/recipe.go b/pkg/platform/model/recipe.go index dacf1360cc..008adc4f96 100644 --- a/pkg/platform/model/recipe.go +++ b/pkg/platform/model/recipe.go @@ -32,7 +32,7 @@ type SolverError struct { } func (e *SolverError) Error() string { - return "solver_error" + return "buildplan_error" } func (e *SolverError) Unwrap() error { diff --git a/test/integration/activate_int_test.go b/test/integration/activate_int_test.go index 3e9bdf6ac8..6932fe6b11 100644 --- a/test/integration/activate_int_test.go +++ b/test/integration/activate_int_test.go @@ -198,7 +198,7 @@ func (suite *ActivateIntegrationTestSuite) TestActivatePythonByHostOnly() { cp.ExpectNotExitCode(0) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } } diff --git a/test/integration/checkout_int_test.go b/test/integration/checkout_int_test.go index 0495b01342..6b239d5031 100644 --- a/test/integration/checkout_int_test.go +++ b/test/integration/checkout_int_test.go @@ -97,7 +97,7 @@ func (suite *CheckoutIntegrationTestSuite) TestCheckoutNonEmptyDir() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } // remove file @@ -217,7 +217,7 @@ func (suite *CheckoutIntegrationTestSuite) TestCheckoutNotFound() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } diff --git a/test/integration/install_int_test.go b/test/integration/install_int_test.go new file mode 100644 index 0000000000..0851c5bfd4 --- /dev/null +++ b/test/integration/install_int_test.go @@ -0,0 +1,92 @@ +package integration + +import ( + "strings" + "testing" + + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/testhelpers/e2e" + "github.com/ActiveState/cli/internal/testhelpers/tagsuite" + "github.com/stretchr/testify/suite" +) + +type InstallIntegrationTestSuite struct { + tagsuite.Suite +} + +func (suite *InstallIntegrationTestSuite) TestInstall() { + suite.OnlyRunForTags(tagsuite.Install, tagsuite.Critical) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.PrepareProject("ActiveState-CLI/small-python", "5a1e49e5-8ceb-4a09-b605-ed334474855b") + cp := ts.SpawnWithOpts(e2e.OptArgs("install", "trender")) + cp.Expect("Package added") + cp.ExpectExitCode(0) +} + +func (suite *InstallIntegrationTestSuite) TestInstall_InvalidCommit() { + suite.OnlyRunForTags(tagsuite.Install) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.PrepareProject("ActiveState-CLI/small-python", "malformed-commit-id") + cp := ts.SpawnWithOpts(e2e.OptArgs("install", "trender")) + cp.Expect("Could not find or read the commit file") + cp.ExpectExitCode(1) + + if strings.Count(cp.Snapshot(), " x ") != 1 { + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) + } +} + +func (suite *InstallIntegrationTestSuite) TestInstall_NoMatches_NoAlternatives() { + suite.OnlyRunForTags(tagsuite.Install) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.PrepareProject("ActiveState-CLI/small-python", "5a1e49e5-8ceb-4a09-b605-ed334474855b") + cp := ts.SpawnWithOpts(e2e.OptArgs("install", "I-dont-exist")) + cp.Expect("No results found for search term") + cp.Expect("find alternatives") // This verifies no alternatives were found + cp.ExpectExitCode(1) + + if strings.Count(strings.ReplaceAll(cp.Snapshot(), " x Failed", ""), " x ") != 1 { + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) + } +} + +func (suite *InstallIntegrationTestSuite) TestInstall_NoMatches_Alternatives() { + suite.OnlyRunForTags(tagsuite.Install) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.PrepareProject("ActiveState-CLI/small-python", "5a1e49e5-8ceb-4a09-b605-ed334474855b") + cp := ts.SpawnWithOpts(e2e.OptArgs("install", "database")) + cp.Expect("No results found for search term") + cp.Expect("did you mean") // This verifies alternatives were found + cp.ExpectExitCode(1) + + if strings.Count(strings.ReplaceAll(cp.Snapshot(), " x Failed", ""), " x ") != 1 { + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) + } +} + +func (suite *InstallIntegrationTestSuite) TestInstall_BuildPlannerError() { + suite.OnlyRunForTags(tagsuite.Install) + ts := e2e.New(suite.T(), false) + defer ts.Close() + + ts.PrepareProject("ActiveState-CLI/small-python", "d8f26b91-899c-4d50-8310-2c338786aa0f") + cp := ts.SpawnWithOpts(e2e.OptArgs("install", "trender@999.0"), e2e.OptAppendEnv(constants.DisableRuntime+"=true")) + cp.Expect("Could not plan build, platform responded with", e2e.RuntimeSourcingTimeoutOpt) + cp.ExpectExitCode(1) + + if strings.Count(strings.ReplaceAll(cp.Snapshot(), " x Failed", ""), " x ") != 1 { + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) + } +} + +func TestInstallIntegrationTestSuite(t *testing.T) { + suite.Run(t, new(InstallIntegrationTestSuite)) +} diff --git a/test/integration/push_int_test.go b/test/integration/push_int_test.go index dc24ce4093..60937938aa 100644 --- a/test/integration/push_int_test.go +++ b/test/integration/push_int_test.go @@ -276,7 +276,7 @@ func (suite *PushIntegrationTestSuite) TestPush_NoProject() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } @@ -293,7 +293,7 @@ func (suite *PushIntegrationTestSuite) TestPush_NoAuth() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } @@ -312,7 +312,7 @@ func (suite *PushIntegrationTestSuite) TestPush_NoChanges() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } @@ -330,7 +330,7 @@ func (suite *PushIntegrationTestSuite) TestPush_NoCommit() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } @@ -350,7 +350,7 @@ func (suite *PushIntegrationTestSuite) TestPush_NameInUse() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } @@ -375,7 +375,7 @@ func (suite *PushIntegrationTestSuite) TestPush_Aborted() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } @@ -395,7 +395,7 @@ func (suite *PushIntegrationTestSuite) TestPush_InvalidHistory() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } } @@ -414,7 +414,7 @@ func (suite *PushIntegrationTestSuite) TestPush_PullNeeded() { cp.ExpectExitCode(1) if strings.Count(cp.Snapshot(), " x ") != 1 { - suite.Fail("Expected exactly ONE error message, got: %s", cp.Snapshot()) + suite.Fail("Expected exactly ONE error message, got: ", cp.Snapshot()) } }