cuelang.org/go@v0.13.0/encoding/jsonschema/external_test.go (about)

     1  // Copyright 2024 CUE Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package jsonschema_test
    16  
    17  import (
    18  	stdjson "encoding/json"
    19  	"fmt"
    20  	"io"
    21  	"maps"
    22  	"os"
    23  	"path"
    24  	"regexp"
    25  	"slices"
    26  	"strings"
    27  	"testing"
    28  
    29  	"github.com/go-quicktest/qt"
    30  
    31  	"cuelang.org/go/cue"
    32  	"cuelang.org/go/cue/errors"
    33  	"cuelang.org/go/cue/format"
    34  	"cuelang.org/go/cue/token"
    35  	"cuelang.org/go/encoding/json"
    36  	"cuelang.org/go/encoding/jsonschema"
    37  	"cuelang.org/go/encoding/jsonschema/internal/externaltest"
    38  	"cuelang.org/go/internal/cuetdtest"
    39  	"cuelang.org/go/internal/cuetest"
    40  )
    41  
    42  // Pull in the external test data.
    43  // The commit below references the JSON schema test main branch as of Sun May 19 19:01:03 2024 +0300
    44  
    45  //go:generate go run vendor_external.go -- 9fc880bfb6d8ccd093bc82431f17d13681ffae8e
    46  
    47  const testDir = "testdata/external"
    48  
    49  // TestExternal runs the externally defined JSON Schema test suite,
    50  // as defined in https://github.com/json-schema-org/JSON-Schema-Test-Suite.
    51  func TestExternal(t *testing.T) {
    52  	tests, err := externaltest.ReadTestDir(testDir)
    53  	qt.Assert(t, qt.IsNil(err))
    54  
    55  	// Group the tests under a single subtest so that we can use
    56  	// t.Parallel and still guarantee that all tests have completed
    57  	// by the end.
    58  	cuetdtest.SmallMatrix.Run(t, "tests", func(t *testing.T, m *cuetdtest.M) {
    59  		// Run tests in deterministic order so we get some consistency between runs.
    60  		for _, filename := range slices.Sorted(maps.Keys(tests)) {
    61  			schemas := tests[filename]
    62  			t.Run(testName(filename), func(t *testing.T) {
    63  				for _, s := range schemas {
    64  					t.Run(testName(s.Description), func(t *testing.T) {
    65  						runExternalSchemaTests(t, m, filename, s)
    66  					})
    67  				}
    68  			})
    69  		}
    70  	})
    71  	if !cuetest.UpdateGoldenFiles {
    72  		return
    73  	}
    74  	if t.Failed() {
    75  		t.Fatalf("not writing test data back because of test failures (try CUE_UPDATE=force to proceed regardless of test regressions)")
    76  	}
    77  	err = externaltest.WriteTestDir(testDir, tests)
    78  	qt.Assert(t, qt.IsNil(err))
    79  	err = writeExternalTestStats(testDir, tests)
    80  	qt.Assert(t, qt.IsNil(err))
    81  }
    82  
    83  var rxCharacterClassCategoryAlias = regexp.MustCompile(`\\p{(Cased_Letter|Close_Punctuation|Combining_Mark|Connector_Punctuation|Control|Currency_Symbol|Dash_Punctuation|Decimal_Number|Enclosing_Mark|Final_Punctuation|Format|Initial_Punctuation|Letter|Letter_Number|Line_Separator|Lowercase_Letter|Mark|Math_Symbol|Modifier_Letter|Modifier_Symbol|Nonspacing_Mark|Number|Open_Punctuation|Other|Other_Letter|Other_Number|Other_Punctuation|Other_Symbol|Paragraph_Separator|Private_Use|Punctuation|Separator|Space_Separator|Spacing_Mark|Surrogate|Symbol|Titlecase_Letter|Unassigned|Uppercase_Letter|cntrl|digit|punct)}`)
    84  
    85  var supportsCharacterClassCategoryAlias = func() bool {
    86  	//lint:ignore SA1000 this regular expression is meant to fail to compile on Go 1.24 and earlier
    87  	_, err := regexp.Compile(`\p{Letter}`)
    88  	return err == nil
    89  }()
    90  
    91  func runExternalSchemaTests(t *testing.T, m *cuetdtest.M, filename string, s *externaltest.Schema) {
    92  	t.Logf("file %v", path.Join("testdata/external", filename))
    93  	ctx := m.CueContext()
    94  	jsonAST, err := json.Extract("schema.json", s.Schema)
    95  	qt.Assert(t, qt.IsNil(err))
    96  	jsonValue := ctx.BuildExpr(jsonAST)
    97  	qt.Assert(t, qt.IsNil(jsonValue.Err()))
    98  	versStr, _, _ := strings.Cut(strings.TrimPrefix(filename, "tests/"), "/")
    99  	vers, ok := extVersionToVersion[versStr]
   100  	if !ok {
   101  		t.Fatalf("unknown JSON schema version for file %q", filename)
   102  	}
   103  	if vers == jsonschema.VersionUnknown {
   104  		t.Skipf("skipping test for unknown schema version %v", versStr)
   105  	}
   106  
   107  	// The upcoming Go 1.25 implements Unicode category aliases in regular expressions,
   108  	// such that e.g. \p{Letter} begins working on Go tip and 1.25 pre-releases.
   109  	// Our tests must run on the latest two stable Go versions, currently 1.23 and 1.24,
   110  	// where such character classes lead to schema compilation errors.
   111  	//
   112  	// As a temporary compromise, only run these tests on the broken and older Go versions.
   113  	// With the testdata files being updated with the latest stable Go, 1.24,
   114  	// this results in testdata reflecting what most Go users see with the latest Go,
   115  	// while we are still able to use `go test` normally with Go tip.
   116  	// TODO: swap around to expect the fixed behavior once Go 1.25.0 is released.
   117  	// TODO: get rid of this whole thing once we require Go 1.25 or later in the future.
   118  	if rxCharacterClassCategoryAlias.Match(s.Schema) && supportsCharacterClassCategoryAlias {
   119  		t.Skip("regexp character classes for Unicode category aliases work only on Go 1.25 and later")
   120  	}
   121  
   122  	schemaAST, extractErr := jsonschema.Extract(jsonValue, &jsonschema.Config{
   123  		StrictFeatures: true,
   124  		DefaultVersion: vers,
   125  	})
   126  	var schemaValue cue.Value
   127  	if extractErr == nil {
   128  		// Round-trip via bytes because that's what will usually happen
   129  		// to the generated schema.
   130  		b, err := format.Node(schemaAST, format.Simplify())
   131  		qt.Assert(t, qt.IsNil(err))
   132  		t.Logf("extracted schema: %q", b)
   133  		schemaValue = ctx.CompileBytes(b, cue.Filename("generated.cue"))
   134  		if err := schemaValue.Err(); err != nil {
   135  			extractErr = fmt.Errorf("cannot compile resulting schema: %v", errors.Details(err, nil))
   136  		}
   137  	}
   138  
   139  	if extractErr != nil {
   140  		t.Logf("location: %v", testdataPos(s))
   141  		t.Logf("txtar:\n%s", schemaFailureTxtar(s))
   142  		for _, test := range s.Tests {
   143  			t.Run("", func(t *testing.T) {
   144  				testFailed(t, m, &test.Skip, test, "could not compile schema")
   145  			})
   146  		}
   147  		testFailed(t, m, &s.Skip, s, fmt.Sprintf("extract error: %v", extractErr))
   148  		return
   149  	}
   150  	testSucceeded(t, m, &s.Skip, s)
   151  
   152  	for _, test := range s.Tests {
   153  		t.Run(testName(test.Description), func(t *testing.T) {
   154  			defer func() {
   155  				if t.Failed() || testing.Verbose() {
   156  					t.Logf("txtar:\n%s", testCaseTxtar(s, test))
   157  				}
   158  			}()
   159  			t.Logf("location: %v", testdataPos(test))
   160  			instAST, err := json.Extract("instance.json", test.Data)
   161  			if err != nil {
   162  				t.Fatal(err)
   163  			}
   164  
   165  			qt.Assert(t, qt.IsNil(err), qt.Commentf("test data: %q; details: %v", test.Data, errors.Details(err, nil)))
   166  
   167  			instValue := ctx.BuildExpr(instAST)
   168  			qt.Assert(t, qt.IsNil(instValue.Err()))
   169  			err = instValue.Unify(schemaValue).Validate(cue.Concrete(true))
   170  			if test.Valid {
   171  				if err != nil {
   172  					testFailed(t, m, &test.Skip, test, errors.Details(err, nil))
   173  				} else {
   174  					testSucceeded(t, m, &test.Skip, test)
   175  				}
   176  			} else {
   177  				if err == nil {
   178  					testFailed(t, m, &test.Skip, test, "unexpected success")
   179  				} else {
   180  					testSucceeded(t, m, &test.Skip, test)
   181  				}
   182  			}
   183  		})
   184  	}
   185  }
   186  
   187  // testCaseTxtar returns a testscript that runs the given test.
   188  func testCaseTxtar(s *externaltest.Schema, test *externaltest.Test) string {
   189  	var buf strings.Builder
   190  	fmt.Fprintf(&buf, "env CUE_EXPERIMENT=evalv3\n")
   191  	fmt.Fprintf(&buf, "exec cue def json+jsonschema: schema.json\n")
   192  	if !test.Valid {
   193  		buf.WriteString("! ")
   194  	}
   195  	// TODO add $schema when one isn't already present?
   196  	fmt.Fprintf(&buf, "exec cue vet -c instance.json json+jsonschema: schema.json\n")
   197  	fmt.Fprintf(&buf, "\n")
   198  	fmt.Fprintf(&buf, "-- schema.json --\n%s\n", indentJSON(s.Schema))
   199  	fmt.Fprintf(&buf, "-- instance.json --\n%s\n", indentJSON(test.Data))
   200  	return buf.String()
   201  }
   202  
   203  // testCaseTxtar returns a testscript that decodes the given schema.
   204  func schemaFailureTxtar(s *externaltest.Schema) string {
   205  	var buf strings.Builder
   206  	fmt.Fprintf(&buf, "env CUE_EXPERIMENT=evalv3\n")
   207  	fmt.Fprintf(&buf, "exec cue def -o schema.cue json+jsonschema: schema.json\n")
   208  	fmt.Fprintf(&buf, "exec cat schema.cue\n")
   209  	fmt.Fprintf(&buf, "exec cue vet schema.cue\n")
   210  	fmt.Fprintf(&buf, "-- schema.json --\n%s\n", indentJSON(s.Schema))
   211  	return buf.String()
   212  }
   213  
   214  func indentJSON(x stdjson.RawMessage) []byte {
   215  	data, err := stdjson.MarshalIndent(x, "", "\t")
   216  	if err != nil {
   217  		panic(err)
   218  	}
   219  	return data
   220  }
   221  
   222  type positioner interface {
   223  	Pos() token.Pos
   224  }
   225  
   226  // testName returns a test name that doesn't contain any
   227  // slashes because slashes muck with matching.
   228  func testName(s string) string {
   229  	return strings.ReplaceAll(s, "/", "__")
   230  }
   231  
   232  // testFailed marks the current test as failed with the
   233  // given error message, and updates the
   234  // skip field pointed to by skipField if necessary.
   235  func testFailed(t *testing.T, m *cuetdtest.M, skipField *externaltest.Skip, p positioner, errStr string) {
   236  	if cuetest.UpdateGoldenFiles {
   237  		if (*skipField)[m.Name()] == "" && !cuetest.ForceUpdateGoldenFiles {
   238  			t.Fatalf("test regression; was succeeding, now failing: %v", errStr)
   239  		}
   240  		if *skipField == nil {
   241  			*skipField = make(externaltest.Skip)
   242  		}
   243  		(*skipField)[m.Name()] = errStr
   244  		return
   245  	}
   246  	if reason := (*skipField)[m.Name()]; reason != "" {
   247  		qt.Assert(t, qt.Equals(reason, errStr), qt.Commentf("error message mismatch"))
   248  		t.Skipf("skipping due to known error: %v", reason)
   249  	}
   250  	t.Fatal(errStr)
   251  }
   252  
   253  // testFails marks the current test as succeeded and updates the
   254  // skip field pointed to by skipField if necessary.
   255  func testSucceeded(t *testing.T, m *cuetdtest.M, skipField *externaltest.Skip, p positioner) {
   256  	if cuetest.UpdateGoldenFiles {
   257  		delete(*skipField, m.Name())
   258  		if len(*skipField) == 0 {
   259  			*skipField = nil
   260  		}
   261  		return
   262  	}
   263  	if reason := (*skipField)[m.Name()]; reason != "" {
   264  		t.Fatalf("unexpectedly more correct behavior (test success) on skipped test")
   265  	}
   266  }
   267  
   268  func testdataPos(p positioner) token.Position {
   269  	pp := p.Pos().Position()
   270  	pp.Filename = path.Join(testDir, pp.Filename)
   271  	return pp
   272  }
   273  
   274  var extVersionToVersion = map[string]jsonschema.Version{
   275  	"draft3":       jsonschema.VersionUnknown,
   276  	"draft4":       jsonschema.VersionDraft4,
   277  	"draft6":       jsonschema.VersionDraft6,
   278  	"draft7":       jsonschema.VersionDraft7,
   279  	"draft2019-09": jsonschema.VersionDraft2019_09,
   280  	"draft2020-12": jsonschema.VersionDraft2020_12,
   281  	"draft-next":   jsonschema.VersionUnknown,
   282  }
   283  
   284  func writeExternalTestStats(testDir string, tests map[string][]*externaltest.Schema) error {
   285  	outf, err := os.Create("external_teststats.txt")
   286  	if err != nil {
   287  		return err
   288  	}
   289  	defer outf.Close()
   290  	fmt.Fprintf(outf, "# Generated by CUE_UPDATE=1 go test. DO NOT EDIT\n")
   291  	fmt.Fprintf(outf, "v2:\n")
   292  	showStats(outf, "v2", false, tests)
   293  	fmt.Fprintf(outf, "\n")
   294  	fmt.Fprintf(outf, "v3:\n")
   295  	showStats(outf, "v3", false, tests)
   296  	fmt.Fprintf(outf, "\nOptional tests\n\n")
   297  	fmt.Fprintf(outf, "v2:\n")
   298  	showStats(outf, "v2", true, tests)
   299  	fmt.Fprintf(outf, "\n")
   300  	fmt.Fprintf(outf, "v3:\n")
   301  	showStats(outf, "v3", true, tests)
   302  	return nil
   303  }
   304  
   305  func showStats(outw io.Writer, version string, showOptional bool, tests map[string][]*externaltest.Schema) {
   306  	schemaOK := 0
   307  	schemaTot := 0
   308  	testOK := 0
   309  	testTot := 0
   310  	schemaOKTestOK := 0
   311  	schemaOKTestTot := 0
   312  	for filename, schemas := range tests {
   313  		isOptional := strings.Contains(filename, "/optional/")
   314  		if isOptional != showOptional {
   315  			continue
   316  		}
   317  		for _, schema := range schemas {
   318  			schemaTot++
   319  			if schema.Skip[version] == "" {
   320  				schemaOK++
   321  			}
   322  			for _, test := range schema.Tests {
   323  				testTot++
   324  				if test.Skip[version] == "" {
   325  					testOK++
   326  				}
   327  				if schema.Skip[version] == "" {
   328  					schemaOKTestTot++
   329  					if test.Skip[version] == "" {
   330  						schemaOKTestOK++
   331  					}
   332  				}
   333  			}
   334  		}
   335  	}
   336  	fmt.Fprintf(outw, "\tschema extract (pass / total): %d / %d = %.1f%%\n", schemaOK, schemaTot, percent(schemaOK, schemaTot))
   337  	fmt.Fprintf(outw, "\ttests (pass / total): %d / %d = %.1f%%\n", testOK, testTot, percent(testOK, testTot))
   338  	fmt.Fprintf(outw, "\ttests on extracted schemas (pass / total): %d / %d = %.1f%%\n", schemaOKTestOK, schemaOKTestTot, percent(schemaOKTestOK, schemaOKTestTot))
   339  }
   340  
   341  func percent(a, b int) float64 {
   342  	return (float64(a) / float64(b)) * 100.0
   343  }