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 <victor.solanonunez@gmail.com>
This commit is contained in:
Victor
2026-07-27 23:24:57 +02:00
committed by GitHub
parent 986642777c
commit 69d2cc04a0
2 changed files with 130 additions and 0 deletions
+44
View File
@@ -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
+86
View File
@@ -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)
}
}