this repo has no description
at master 458 lines 16 kB view raw
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 15package jsonschema_test 16 17import ( 18 "bytes" 19 stdjson "encoding/json" 20 "fmt" 21 "io" 22 "maps" 23 "net/url" 24 "os" 25 "path" 26 "regexp" 27 "slices" 28 "strings" 29 "testing" 30 31 "github.com/go-quicktest/qt" 32 33 "cuelang.org/go/cue" 34 "cuelang.org/go/cue/errors" 35 "cuelang.org/go/cue/format" 36 "cuelang.org/go/cue/token" 37 "cuelang.org/go/encoding/json" 38 "cuelang.org/go/encoding/jsonschema" 39 "cuelang.org/go/encoding/jsonschema/internal/externaltest" 40 "cuelang.org/go/internal/cuetdtest" 41 "cuelang.org/go/internal/cuetest" 42) 43 44// Pull in the external test data. 45// The commit below references the JSON schema test main branch as of Sun May 19 19:01:03 2024 +0300 46 47//go:generate go run vendor_external.go -- 9fc880bfb6d8ccd093bc82431f17d13681ffae8e 48 49const testDir = "testdata/external" 50 51// TestExternal runs the externally defined JSON Schema test suite, 52// as defined in https://github.com/json-schema-org/JSON-Schema-Test-Suite. 53func TestExternal(t *testing.T) { 54 t.Parallel() 55 tests, err := externaltest.ReadTestDir(testDir) 56 qt.Assert(t, qt.IsNil(err)) 57 58 // Group the tests under a single subtest so that we can use 59 // t.Parallel and still guarantee that all tests have completed 60 // by the end. 61 cuetdtest.SmallMatrix.Run(t, "tests", func(t *testing.T, m *cuetdtest.M) { 62 t.Parallel() 63 // Run tests in deterministic order so we get some consistency between runs. 64 for _, filename := range slices.Sorted(maps.Keys(tests)) { 65 schemas := tests[filename] 66 t.Run(testName(filename), func(t *testing.T) { 67 t.Parallel() 68 for _, s := range schemas { 69 t.Run(testName(s.Description), func(t *testing.T) { 70 runExternalSchemaTests(t, m, filename, s) 71 }) 72 } 73 }) 74 } 75 }) 76 if !cuetest.UpdateGoldenFiles { 77 return 78 } 79 if t.Failed() { 80 t.Fatalf("not writing test data back because of test failures (try CUE_UPDATE=force to proceed regardless of test regressions)") 81 } 82 err = externaltest.WriteTestDir(testDir, tests) 83 qt.Assert(t, qt.IsNil(err)) 84 err = writeExternalTestStats(testDir, tests) 85 qt.Assert(t, qt.IsNil(err)) 86} 87 88var 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)}`) 89 90var supportsCharacterClassCategoryAlias = func() bool { 91 _, err := regexp.Compile(`\p{Letter}`) 92 return err == nil 93}() 94 95var fixesParsingIPv6HostWithoutBrackets = func() bool { 96 // We use Sprintf so that staticcheck on Go 1.26 and later does not 97 // helpfully report that this URL will always fail to parse. 98 _, err := url.Parse(fmt.Sprintf("%s://2001:0db8:85a3:0000:0000:8a2e:0370:7334", "http")) 99 return err != nil 100}() 101 102func runExternalSchemaTests(t *testing.T, m *cuetdtest.M, filename string, s *externaltest.Schema) { 103 t.Logf("file %v", path.Join("testdata/external", filename)) 104 ctx := m.CueContext() 105 jsonAST, err := json.Extract("schema.json", s.Schema) 106 qt.Assert(t, qt.IsNil(err)) 107 jsonValue := ctx.BuildExpr(jsonAST) 108 qt.Assert(t, qt.IsNil(jsonValue.Err())) 109 versStr, _, _ := strings.Cut(strings.TrimPrefix(filename, "tests/"), "/") 110 vers, ok := extVersionToVersion[versStr] 111 if !ok { 112 t.Fatalf("unknown JSON schema version for file %q", filename) 113 } 114 maybeSkip(t, vers, versStr, s) 115 t.Logf("location: %v", testdataPos(s)) 116 117 // Extract the schema from the test data JSON schema. 118 schemaAST, extractErr := jsonschema.Extract(jsonValue, &jsonschema.Config{ 119 StrictFeatures: true, 120 DefaultVersion: vers, 121 }) 122 var schemaValue cue.Value 123 if extractErr == nil { 124 // Round-trip via bytes because that's what will usually happen 125 // to the generated schema. 126 b, err := format.Node(schemaAST, format.Simplify()) 127 qt.Assert(t, qt.IsNil(err)) 128 t.Logf("extracted schema: %q", b) 129 schemaValue = ctx.CompileBytes(b, cue.Filename("generated.cue")) 130 if err := schemaValue.Err(); err != nil { 131 extractErr = fmt.Errorf("cannot compile resulting schema: %v", errors.Details(err, nil)) 132 } 133 } 134 t.Run("Extract", func(t *testing.T) { 135 if extractErr != nil { 136 t.Logf("txtar:\n%s", schemaFailureTxtar(s)) 137 schemaExtractFailed(t, m, "", s, fmt.Sprintf("extract error: %v", extractErr)) 138 return 139 } 140 testSucceeded(t, m, "", &s.Skip, s) 141 for _, test := range s.Tests { 142 t.Run(testName(test.Description), func(t *testing.T) { 143 runExternalSchemaTest(t, m, "", s, test, schemaValue) 144 }) 145 } 146 }) 147 148 t.Run("RoundTrip", func(t *testing.T) { 149 // Run Generate round-trip tests for draft2020-12 only 150 const supportedVersion = jsonschema.VersionDraft2020_12 151 const variant = "roundtrip" 152 var roundTripSchemaValue cue.Value 153 var roundTripErr error 154 switch { 155 case extractErr != nil: 156 roundTripErr = fmt.Errorf("inital extract failed") 157 case vers != supportedVersion: 158 // Generation only supports 2020-12 currently 159 roundTripErr = fmt.Errorf("generation only supported in version %v", supportedVersion) 160 default: 161 roundTripSchemaValue, roundTripErr = roundTripViaGenerate(t, schemaValue) 162 } 163 if roundTripErr != nil { 164 schemaExtractFailed(t, m, variant, s, roundTripErr.Error()) 165 return 166 } 167 testSucceeded(t, m, variant, &s.Skip, s) 168 for _, test := range s.Tests { 169 t.Run(testName(test.Description), func(t *testing.T) { 170 runExternalSchemaTest(t, m, variant, s, test, roundTripSchemaValue) 171 }) 172 } 173 }) 174} 175 176// schemaExtractFailed marks a schema extraction as failed and also 177// runs all the subtests, marking them as failed too. 178func schemaExtractFailed(t *testing.T, m *cuetdtest.M, variant string, s *externaltest.Schema, reason string) { 179 for _, test := range s.Tests { 180 t.Run("", func(t *testing.T) { 181 testFailed(t, m, variant, &test.Skip, test, "could not extract schema") 182 }) 183 } 184 testFailed(t, m, variant, &s.Skip, s, reason) 185} 186 187func maybeSkip(t *testing.T, vers jsonschema.Version, versStr string, s *externaltest.Schema) { 188 switch { 189 case vers == jsonschema.VersionUnknown: 190 t.Skipf("skipping test for unknown schema version %v", versStr) 191 192 case rxCharacterClassCategoryAlias.Match(s.Schema) && !supportsCharacterClassCategoryAlias: 193 // Go 1.25 implements Unicode category aliases in regular expressions, 194 // and so e.g. \p{Letter} did not work on Go 1.24.x releases. 195 // See: https://github.com/golang/go/issues/70780 196 // Our tests must run on the latest two stable Go versions, currently 1.24 and 1.25, 197 // where such character classes lead to schema compilation errors on 1.24. 198 // 199 // As a temporary compromise, only run these tests on Go 1.25 or later. 200 // TODO: get rid of this whole thing once we require Go 1.25 or later in the future. 201 t.Skip("regexp character classes for Unicode category aliases work only on Go 1.25 and later") 202 203 case bytes.Contains(s.Schema, []byte(`"iri"`)) && fixesParsingIPv6HostWithoutBrackets: 204 // Go 1.26 fixes [url.Parse] so that it correctly rejects IPv6 hosts 205 // without the required surrounding square brackets. 206 // See: https://github.com/golang/go/issues/31024 207 // Our tests must run on the latest two stable Go versions, currently 1.24 and 1.25, 208 // where such behavior is still buggy. 209 // 210 // As a temporary compromise, skip the test on 1.26 or later; 211 // we care about testing the behavior that most CUE users will see today. 212 // TODO: get rid of this whole thing once we require Go 1.26 or later in the future. 213 t.Skip("net/url.Parse tightens behavior on IPv6 hosts on Go 1.26 and later") 214 } 215} 216 217// runExternalSchemaTest runs a single test case against a given schema value. 218func runExternalSchemaTest(t *testing.T, m *cuetdtest.M, variant string, s *externaltest.Schema, test *externaltest.Test, schemaValue cue.Value) { 219 ctx := schemaValue.Context() 220 defer func() { 221 if t.Failed() || testing.Verbose() { 222 t.Logf("txtar:\n%s", testCaseTxtar(s, test)) 223 } 224 }() 225 t.Logf("location: %v", testdataPos(test)) 226 instAST, err := json.Extract("instance.json", test.Data) 227 if err != nil { 228 t.Fatal(err) 229 } 230 231 qt.Assert(t, qt.IsNil(err), qt.Commentf("test data: %q; details: %v", test.Data, errors.Details(err, nil))) 232 233 instValue := ctx.BuildExpr(instAST) 234 qt.Assert(t, qt.IsNil(instValue.Err())) 235 err = instValue.Unify(schemaValue).Validate(cue.Concrete(true)) 236 if test.Valid { 237 if err != nil { 238 testFailed(t, m, variant, &test.Skip, test, errors.Details(err, nil)) 239 } else { 240 testSucceeded(t, m, variant, &test.Skip, test) 241 } 242 } else { 243 if err == nil { 244 testFailed(t, m, variant, &test.Skip, test, "unexpected success") 245 } else { 246 testSucceeded(t, m, variant, &test.Skip, test) 247 } 248 } 249} 250 251// roundTripViaGenerate takes a CUE schema as produced by Extract, 252// invokes Generate on it, then returns the result of invoking Extract on 253// the result of that. 254func roundTripViaGenerate(t *testing.T, schemaValue cue.Value) (cue.Value, error) { 255 ctx := schemaValue.Context() 256 // Generate JSON Schema from the extracted CUE. 257 // Note: 2020_12 is the only version that we currently support. 258 syn := schemaValue.Syntax() 259 data, err := format.Node(syn) 260 qt.Assert(t, qt.IsNil(err)) 261 schemaValue = ctx.CompileBytes(data) 262 t.Logf("extracted schema: %q", data) 263 jsonAST, err := jsonschema.Generate(schemaValue, &jsonschema.GenerateConfig{ 264 Version: jsonschema.VersionDraft2020_12, 265 }) 266 if err != nil { 267 return cue.Value{}, fmt.Errorf("generate error: %v", err) 268 } 269 jsonValue := ctx.BuildExpr(jsonAST) 270 if err := jsonValue.Err(); err != nil { 271 // This really shouldn't happen. 272 return cue.Value{}, fmt.Errorf("cannot build value from JSON: %v", err) 273 } 274 t.Logf("generated JSON schema: %v", jsonValue) 275 276 generatedSchemaAST, err := jsonschema.Extract(jsonValue, &jsonschema.Config{ 277 StrictFeatures: true, 278 }) 279 if err != nil { 280 return cue.Value{}, fmt.Errorf("cannot extract generated schema: %v", err) 281 } 282 schemaValue1 := ctx.BuildFile(generatedSchemaAST) 283 if err := schemaValue1.Err(); err != nil { 284 return cue.Value{}, fmt.Errorf("cannot build extracted schema: %v", err) 285 } 286 t.Logf("round-tripped CUE schema: %#v", schemaValue1) 287 return schemaValue1, nil 288} 289 290// testCaseTxtar returns a testscript that runs the given test. 291func testCaseTxtar(s *externaltest.Schema, test *externaltest.Test) string { 292 var buf strings.Builder 293 fmt.Fprintf(&buf, "exec cue def json+jsonschema: schema.json\n") 294 if !test.Valid { 295 buf.WriteString("! ") 296 } 297 // TODO add $schema when one isn't already present? 298 fmt.Fprintf(&buf, "exec cue vet -c instance.json json+jsonschema: schema.json\n") 299 fmt.Fprintf(&buf, "\n") 300 fmt.Fprintf(&buf, "-- schema.json --\n%s\n", indentJSON(s.Schema)) 301 fmt.Fprintf(&buf, "-- instance.json --\n%s\n", indentJSON(test.Data)) 302 return buf.String() 303} 304 305// testCaseTxtar returns a testscript that decodes the given schema. 306func schemaFailureTxtar(s *externaltest.Schema) string { 307 var buf strings.Builder 308 fmt.Fprintf(&buf, "exec cue def -o schema.cue json+jsonschema: schema.json\n") 309 fmt.Fprintf(&buf, "exec cat schema.cue\n") 310 fmt.Fprintf(&buf, "exec cue vet schema.cue\n") 311 fmt.Fprintf(&buf, "-- schema.json --\n%s\n", indentJSON(s.Schema)) 312 return buf.String() 313} 314 315func indentJSON(x stdjson.RawMessage) []byte { 316 data, err := stdjson.MarshalIndent(x, "", "\t") 317 if err != nil { 318 panic(err) 319 } 320 return data 321} 322 323type positioner interface { 324 Pos() token.Pos 325} 326 327// testName returns a test name that doesn't contain any 328// slashes because slashes muck with matching. 329func testName(s string) string { 330 return strings.ReplaceAll(s, "/", "__") 331} 332 333// testFailed marks the current test as failed with the 334// given error message, and updates the 335// skip field pointed to by skipField if necessary. 336func testFailed(t *testing.T, m *cuetdtest.M, variant string, skipField *externaltest.Skip, p positioner, errStr string) { 337 name := skipName(m, variant) 338 if cuetest.UpdateGoldenFiles { 339 if (*skipField)[name] == "" && !cuetest.ForceUpdateGoldenFiles { 340 t.Fatalf("test regression; was succeeding, now failing: %v", errStr) 341 } 342 if *skipField == nil { 343 *skipField = make(externaltest.Skip) 344 } 345 (*skipField)[name] = errStr 346 return 347 } 348 if reason := (*skipField)[name]; reason != "" { 349 qt.Assert(t, qt.Equals(reason, errStr), qt.Commentf("error message mismatch")) 350 t.Skipf("skipping due to known error: %v", reason) 351 } 352 t.Fatal(errStr) 353} 354 355// testFails marks the current test as succeeded and updates the 356// skip field pointed to by skipField if necessary. 357func testSucceeded(t *testing.T, m *cuetdtest.M, variant string, skipField *externaltest.Skip, p positioner) { 358 name := skipName(m, variant) 359 if cuetest.UpdateGoldenFiles { 360 delete(*skipField, name) 361 if len(*skipField) == 0 { 362 *skipField = nil 363 } 364 return 365 } 366 if reason := (*skipField)[name]; reason != "" { 367 t.Fatalf("unexpectedly more correct behavior (test success) on skipped test") 368 } 369} 370 371// skipName returns the key to use in the skip field for the 372// given matrix entry and test variant. 373func skipName(m *cuetdtest.M, variant string) string { 374 name := m.Name() 375 if variant != "" { 376 name += "-" + variant 377 } 378 return name 379} 380 381func testdataPos(p positioner) token.Position { 382 pp := p.Pos().Position() 383 pp.Filename = path.Join(testDir, pp.Filename) 384 return pp 385} 386 387var extVersionToVersion = map[string]jsonschema.Version{ 388 "draft3": jsonschema.VersionUnknown, 389 "draft4": jsonschema.VersionDraft4, 390 "draft6": jsonschema.VersionDraft6, 391 "draft7": jsonschema.VersionDraft7, 392 "draft2019-09": jsonschema.VersionDraft2019_09, 393 "draft2020-12": jsonschema.VersionDraft2020_12, 394 "draft-next": jsonschema.VersionUnknown, 395} 396 397func writeExternalTestStats(testDir string, tests map[string][]*externaltest.Schema) error { 398 outf, err := os.Create("external_teststats.txt") 399 if err != nil { 400 return err 401 } 402 defer outf.Close() 403 fmt.Fprintf(outf, "# Generated by CUE_UPDATE=1 go test. DO NOT EDIT\n") 404 variants := []string{ 405 "v3", 406 "v3-roundtrip", 407 } 408 for _, opt := range []string{"Core", "Optional"} { 409 fmt.Fprintf(outf, "\n%s tests:\n", opt) 410 for _, v := range variants { 411 fmt.Fprintf(outf, "\n%s:\n", v) 412 showStats(outf, v, opt == "Optional", tests) 413 } 414 } 415 return nil 416} 417 418func showStats(outw io.Writer, version string, showOptional bool, tests map[string][]*externaltest.Schema) { 419 schemaOK := 0 420 schemaTot := 0 421 testOK := 0 422 testTot := 0 423 schemaOKTestOK := 0 424 schemaOKTestTot := 0 425 for filename, schemas := range tests { 426 isOptional := strings.Contains(filename, "/optional/") 427 if isOptional != showOptional { 428 continue 429 } 430 for _, schema := range schemas { 431 schemaTot++ 432 schemaSkipped := schema.Skip[version] != "" 433 if !schemaSkipped { 434 schemaOK++ 435 } 436 for _, test := range schema.Tests { 437 testSkipped := test.Skip[version] != "" 438 testTot++ 439 if !testSkipped { 440 testOK++ 441 } 442 if !schemaSkipped { 443 schemaOKTestTot++ 444 if !testSkipped { 445 schemaOKTestOK++ 446 } 447 } 448 } 449 } 450 } 451 fmt.Fprintf(outw, "\tschema extract (pass / total): %d / %d = %.1f%%\n", schemaOK, schemaTot, percent(schemaOK, schemaTot)) 452 fmt.Fprintf(outw, "\ttests (pass / total): %d / %d = %.1f%%\n", testOK, testTot, percent(testOK, testTot)) 453 fmt.Fprintf(outw, "\ttests on extracted schemas (pass / total): %d / %d = %.1f%%\n", schemaOKTestOK, schemaOKTestTot, percent(schemaOKTestOK, schemaOKTestTot)) 454} 455 456func percent(a, b int) float64 { 457 return (float64(a) / float64(b)) * 100.0 458}