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}