From 69d2cc04a0de02892e4bad95cc416b9baa9a0b3b Mon Sep 17 00:00:00 2001 From: Victor <70475442+vsolano9@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:24:57 +0200 Subject: [PATCH] tester: make Result JSON round-trippable (#8946) ## Summary - add a concrete JSON unmarshal path for tester.Result errors - preserve the existing marshaled output byte-for-byte - reconstruct structured topdown errors from opa test --format json output Fixes #8014 ## Testing - go test -count=1 ./v1/tester/... ./tester/... - go test -count=1 ./cmd/... - go build ./... - go vet ./v1/tester/... ./tester/... ./cmd/... - gofmt check on the changed Go files Signed-off-by: Victor Solano --- v1/tester/runner.go | 44 ++++++++++++++++++++ v1/tester/runner_test.go | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/v1/tester/runner.go b/v1/tester/runner.go index 4276dd924e..3c3a3f468d 100644 --- a/v1/tester/runner.go +++ b/v1/tester/runner.go @@ -175,6 +175,50 @@ func newResult(loc *ast.Location, pkg, name string, duration time.Duration, trac } } +// resultError mirrors the JSON shape *topdown.Error marshals to, which is the +// form errors take in the output of `opa test --format json`. +type resultError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Location *ast.Location `json:"location,omitempty"` +} + +func (e *resultError) error() error { + if e == nil { + return nil + } + + if e.Code != "" { + return &topdown.Error{Code: e.Code, Message: e.Message, Location: e.Location} + } + + return errors.New(e.Message) +} + +// resultAlias drops the methods of Result so that unmarshalling into it below +// doesn't recurse. The anonymous struct shadows the embedded error interface +// field, which is marshallable but not unmarshallable, with a concrete type. +type resultAlias Result + +// UnmarshalJSON reads back the JSON produced for a Result. Marshalling is left +// to the default encoder so that existing output is unchanged. +func (r *Result) UnmarshalJSON(bs []byte) error { + aux := struct { + *resultAlias + Error *resultError `json:"error,omitempty"` + }{ + resultAlias: (*resultAlias)(r), + } + + if err := json.Unmarshal(bs, &aux); err != nil { + return err + } + + r.Error = aux.Error.error() + + return nil +} + // Pass returns true if the test case passed. func (r *Result) Pass() bool { return !r.Fail && !r.Skip && r.Error == nil diff --git a/v1/tester/runner_test.go b/v1/tester/runner_test.go index fbb8be105f..0ec8ed085f 100644 --- a/v1/tester/runner_test.go +++ b/v1/tester/runner_test.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "maps" @@ -1198,3 +1199,88 @@ func TestReporterFormatsWithExplicitParallel(t *testing.T) { }) } } + +func TestResultJSONRoundTrip(t *testing.T) { + loc := ast.NewLocation([]byte("data.foo"), "test.rego", 3, 5) + + tests := []struct { + note string + err error + // exp is the error the round-tripped Result is expected to carry. It + // is nil for errors that have no JSON representation of their own: + // those marshal to an empty object, so only the presence of an error + // survives the round trip. + exp error + }{ + { + note: "plain error", + err: errors.New("test error"), + }, + { + note: "topdown error", + err: &topdown.Error{Code: topdown.CancelErr, Message: "context deadline exceeded"}, + exp: &topdown.Error{Code: topdown.CancelErr, Message: "context deadline exceeded"}, + }, + { + note: "topdown error with location", + err: &topdown.Error{Code: topdown.InternalErr, Message: "boom", Location: loc}, + exp: &topdown.Error{Code: topdown.InternalErr, Message: "boom", Location: loc}, + }, + } + + for _, tc := range tests { + t.Run(tc.note, func(t *testing.T) { + result := &tester.Result{Name: "test_a", Error: tc.err} + + var act tester.Result + if err := util.UnmarshalJSON(util.MustMarshalJSON(result), &act); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if act.Error == nil { + t.Fatal("Expected error to survive the round trip, got nil") + } + + if tc.exp == nil { + return + } + + if act.Error.Error() != tc.exp.Error() { + t.Fatalf("Expected error %q, got %q", tc.exp.Error(), act.Error.Error()) + } + }) + } +} + +func TestResultUnmarshalJSONEvalError(t *testing.T) { + // Payload as emitted by `opa test --format json` when a test errors out. + bs := []byte(`{ + "location": {"file": "main_test.rego", "row": 285, "col": 1}, + "package": "data.regal.main_test", + "name": "test_lint_from_stdin", + "error": {"code": "eval_cancel_error", "message": "context deadline exceeded"}, + "duration": 5009256675 + }`) + + var result tester.Result + if err := util.UnmarshalJSON(bs, &result); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result.Error == nil { + t.Fatal("Expected error, got nil") + } + + var tdErr *topdown.Error + if !errors.As(result.Error, &tdErr) { + t.Fatalf("Expected *topdown.Error, got %T", result.Error) + } + + if tdErr.Code != topdown.CancelErr { + t.Errorf("Expected code %q, got %q", topdown.CancelErr, tdErr.Code) + } + + if exp := "context deadline exceeded"; tdErr.Message != exp { + t.Errorf("Expected message %q, got %q", exp, tdErr.Message) + } +}