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 }