diff --git a/agents/services/base_runtime.go b/agents/services/base_runtime.go index d870ccc9..1cf009f2 100644 --- a/agents/services/base_runtime.go +++ b/agents/services/base_runtime.go @@ -226,8 +226,9 @@ func (s *RuntimeWrapper) TestResponseWithResults(run, passed, failed, skipped in func (s *RuntimeWrapper) TestError(err error) (*runtimev0.TestResponse, error) { s.Lock() defer s.Unlock() - s.TestStatus = &runtimev0.TestStatus{State: runtimev0.TestStatus_ERROR, Message: err.Error(), Failure: operationFailure("runtime.test", err, err.Error())} - return &runtimev0.TestResponse{Status: s.TestStatus}, nil + message := err.Error() + s.TestStatus = &runtimev0.TestStatus{State: runtimev0.TestStatus_ERROR, Message: message, Failure: operationFailure("runtime.test", err, message)} + return typedTestErrorResponse(s.TestStatus, message), nil } func (s *RuntimeWrapper) TestErrorf(err error, msg string, args ...any) (*runtimev0.TestResponse, error) { @@ -235,7 +236,18 @@ func (s *RuntimeWrapper) TestErrorf(err error, msg string, args ...any) (*runtim defer s.Unlock() message := ErrorMessage(err, msg, args...) s.TestStatus = &runtimev0.TestStatus{State: runtimev0.TestStatus_ERROR, Message: message, Failure: operationFailure("runtime.test", err, message)} - return &runtimev0.TestResponse{Status: s.TestStatus}, nil + return typedTestErrorResponse(s.TestStatus, message), nil +} + +// typedTestErrorResponse makes pre-execution failures terminal and explicit. +// A status-only response decodes as TestRunResult_UNKNOWN and loses the +// distinction between a failed test and an environment that never ran tests. +func typedTestErrorResponse(status *runtimev0.TestStatus, message string) *runtimev0.TestResponse { + return &runtimev0.TestResponse{ + Status: status, + Result: &runtimev0.TestRunResult{State: runtimev0.TestRunResult_ERRORED, Message: message}, + Counts: &runtimev0.TestCounts{}, + } } // ── Build ───────────────────────────────────────────────── diff --git a/agents/services/error_contract_test.go b/agents/services/error_contract_test.go index 70923ca8..4b7715b9 100644 --- a/agents/services/error_contract_test.go +++ b/agents/services/error_contract_test.go @@ -22,6 +22,22 @@ func TestRuntimeErrorHelperReturnsStructuredResponseWithoutTransportError(t *tes } } +func TestRuntimeTestErrorIsTypedTerminalResponse(t *testing.T) { + wrapper := &RuntimeWrapper{} + response, err := wrapper.TestError(errors.New("module preparation failed")) + if err != nil { + t.Fatalf("TestError returned transport error: %v", err) + } + if response.GetStatus().GetState() != runtimev0.TestStatus_ERROR || + response.GetResult().GetState() != runtimev0.TestRunResult_ERRORED || + response.GetResult().GetMessage() != "module preparation failed" || response.GetCounts() == nil { + t.Fatalf("TestError response = %+v, want ERROR/ERRORED with explicit zero counts", response) + } + if response.GetStatus().GetFailure().GetOperation() != "runtime.test" { + t.Fatalf("TestError failure = %+v", response.GetStatus().GetFailure()) + } +} + func TestRuntimeLintErrorPreservesFailureOutput(t *testing.T) { wrapper := &RuntimeWrapper{} response, err := wrapper.LintErrorf(errors.New("main.go:4:2: undefined: value"), "lint failed") diff --git a/runners/golang/formula.go b/runners/golang/formula.go index d26dd9f9..14e612de 100644 --- a/runners/golang/formula.go +++ b/runners/golang/formula.go @@ -105,6 +105,10 @@ func ClassifyEnvError(raw string, runErr error) (reason, detail string) { return EnvErrorToolchainMissing, execErr.Error() } } + classificationText := raw + if runErr != nil { + classificationText = strings.TrimSpace(strings.Join([]string{classificationText, runErr.Error()}, "\n")) + } for _, marker := range []string{ "errors parsing go.mod", "unknown directive:", @@ -116,8 +120,8 @@ func ClassifyEnvError(raw string, runErr error) (reason, detail string) { "cannot load module", "unsupported toolchain", } { - if idx := strings.Index(raw, marker); idx >= 0 { - rest := raw[idx:] + if idx := strings.Index(classificationText, marker); idx >= 0 { + rest := classificationText[idx:] line := rest if nl := strings.IndexByte(rest, '\n'); nl >= 0 { line = rest[:nl] diff --git a/runners/golang/formula_test.go b/runners/golang/formula_test.go index ed21a892..b5aa2293 100644 --- a/runners/golang/formula_test.go +++ b/runners/golang/formula_test.go @@ -2,6 +2,7 @@ package golang import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -236,6 +237,13 @@ func TestRunFormula_BrokenGoModIsEnvBlocked(t *testing.T) { } } +func TestClassifyEnvErrorReadsPreExecutionError(t *testing.T) { + reason, detail := ClassifyEnvError("", errors.New("prepare Go dependencies: go: errors parsing go.mod:\ngo.mod:1: unknown directive: modle")) + if reason != EnvErrorModuleBroken || !strings.Contains(detail, "unknown directive") { + t.Fatalf("ClassifyEnvError = %q/%q, want module-broken with parser detail", reason, detail) + } +} + func TestRunFormula_CompileErrorIsNotEnvBlocked(t *testing.T) { dir := writeModule(t, map[string]string{ "go.mod": "module example.com/stringsx\n\ngo 1.21\n", diff --git a/runners/golang/runner.go b/runners/golang/runner.go index 2bd822c5..b2e9c3cd 100644 --- a/runners/golang/runner.go +++ b/runners/golang/runner.go @@ -1,6 +1,7 @@ package golang import ( + "bytes" "context" "fmt" "io" @@ -8,6 +9,7 @@ import ( "os/exec" "path" "path/filepath" + "strings" "github.com/codefly-dev/core/builders" "github.com/codefly-dev/core/resources" @@ -436,9 +438,12 @@ func (r *GoRunnerEnvironment) GoModuleHandling(ctx context.Context) error { if err != nil { return w.Wrapf(err, "cannot create go mod download process for %s", dir) } + var output bytes.Buffer + writer := io.Writer(&output) if r.out != nil { - proc.WithOutput(r.out) + writer = io.MultiWriter(r.out, &output) } + proc.WithOutput(writer) proc.WithDir(dir) // Ambient parent workspaces are disabled; a workspace owned by the // attached source root remains authoritative for all module work. @@ -446,6 +451,9 @@ func (r *GoRunnerEnvironment) GoModuleHandling(ctx context.Context) error { proc.WithEnvironmentVariables(ctx, resources.Env("GOWORK", "off")) } if err := proc.Run(ctx); err != nil { + if detail := strings.TrimSpace(output.String()); detail != "" { + return w.Wrapf(err, "cannot run go mod download in %s: %s", dir, detail) + } return w.Wrapf(err, "cannot run go mod download in %s", dir) } } diff --git a/runners/golang/runner_test.go b/runners/golang/runner_test.go index b2a51ccd..942b055c 100644 --- a/runners/golang/runner_test.go +++ b/runners/golang/runner_test.go @@ -140,6 +140,21 @@ func TestGoModuleHandlingCacheAdvancesOnlyOnDownloadSuccess(t *testing.T) { }) } +func TestGoModuleHandlingPreservesParserDiagnostics(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("modle example.com/broken\n\ngo 1.21\n"), 0o644)) + env, err := golang.NewNativeGoRunner(ctx, root, ".") + require.NoError(t, err) + env.WithLocalCacheDir(t.TempDir()) + t.Cleanup(func() { require.NoError(t, env.Shutdown(context.Background())) }) + + err = env.GoModuleHandling(ctx) + require.Error(t, err) + require.Contains(t, err.Error(), "errors parsing go.mod") + require.Contains(t, err.Error(), "unknown directive: modle") +} + func TestGoRunnerInitDoesNotResolveOrMutateProjectDependencies(t *testing.T) { ctx := context.Background() t.Setenv("GOPROXY", "off")