cuelang.org/go@v0.13.0/internal/cuetxtar/txtar.go (about) 1 // Copyright 2020 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 cuetxtar 16 17 import ( 18 "bufio" 19 "bytes" 20 "fmt" 21 "io" 22 "io/fs" 23 "maps" 24 "os" 25 "path" 26 "path/filepath" 27 "slices" 28 "strings" 29 "testing" 30 31 "cuelang.org/go/cue" 32 "cuelang.org/go/cue/ast" 33 "cuelang.org/go/cue/build" 34 "cuelang.org/go/cue/cuecontext" 35 "cuelang.org/go/cue/errors" 36 "cuelang.org/go/cue/format" 37 "cuelang.org/go/cue/load" 38 "cuelang.org/go/internal/core/runtime" 39 "cuelang.org/go/internal/cuetdtest" 40 "cuelang.org/go/internal/cuetest" 41 "github.com/google/go-cmp/cmp" 42 "github.com/rogpeppe/go-internal/diff" 43 "golang.org/x/tools/txtar" 44 ) 45 46 // A TxTarTest represents a test run that process all CUE tests in the txtar 47 // format rooted in a given directory. See the [Test] documentation for 48 // more details. 49 type TxTarTest struct { 50 // Run TxTarTest on this directory. 51 Root string 52 53 // Name is a unique name for this test. The golden file for this test is 54 // derived from the out/<name> file in the .txtar file. 55 // 56 // TODO: by default derive from the current base directory name. 57 Name string 58 59 // Fallback allows the golden tests named by Fallback to pass tests in 60 // case the golden file corresponding to Name does not exist. 61 // The feature can be used to have two implementations of the same 62 // functionality share the same test sets. 63 Fallback string 64 65 // Skip is a map of tests to skip; the key is the test name; the value is the 66 // skip message. 67 Skip map[string]string 68 69 // ToDo is a map of tests that should be skipped now, but should be fixed. 70 ToDo map[string]string 71 72 // LoadConfig is passed to load.Instances when loading instances. 73 // It's copied before doing that and the Dir and Overlay fields are overwritten. 74 LoadConfig load.Config 75 76 // If Matrix is non-nil, the tests are run for each configuration in the 77 // matrix. 78 Matrix cuetdtest.Matrix 79 80 // DebugArchive, if set, is loaded instead of the on-disk archive. This allows 81 // a test to be used for debugging. 82 DebugArchive string 83 } 84 85 // A Test represents a single test based on a .txtar file. 86 // 87 // A Test embeds [*testing.T] and should be used to report errors. 88 // 89 // Entries within the txtar file define CUE files (available via the 90 // Instances and RawInstances methods) and expected output 91 // (or "golden") files (names starting with "out/\(testname)"). The "main" golden 92 // file is "out/\(testname)" itself, used when [Test] is used directly as an [io.Writer] 93 // and with [Test.WriteFile]. 94 // 95 // When the test function has returned, output written with [Test.Write], [Test.Writer] 96 // and friends is checked against the expected output files. 97 // 98 // A txtar file can define test-specific tags and values in the comment section. 99 // These are available via the [Test.HasTag] and [Test.Value] methods. 100 // The #skip tag causes a [Test] to be skipped. 101 // When running via [cuetdtest.Matrix], #skip-[cuetdtest.M.Name] tags can also be used. 102 // The #noformat tag causes the $CUE_FORMAT_TXTAR value 103 // to be ignored. 104 // 105 // If the output differs and $CUE_UPDATE is non-empty, the txtar file will be 106 // updated and written to disk with the actual output data replacing the 107 // out files. 108 // 109 // If $CUE_FORMAT_TXTAR is non-empty, any CUE files in the txtar 110 // file will be updated to be properly formatted, unless the #noformat 111 // tag is present. 112 type Test struct { 113 // Allow Test to be used as a T. 114 *testing.T 115 *cuetdtest.M 116 117 prefix string 118 fallback string 119 buf *bytes.Buffer // the default buffer 120 outFiles []file 121 122 Archive *txtar.Archive 123 LoadConfig load.Config 124 125 // The absolute path of the current test directory. 126 Dir string 127 128 hasGold bool 129 } 130 131 // Ensure that Test always implements testing.TB. 132 // Note that testing.TB may gain new methods in future Go releases. 133 var _ testing.TB = (*Test)(nil) 134 135 // Write implements [io.Writer] by writing to the output for the test, 136 // which will be tested against the main golden file. 137 func (t *Test) Write(b []byte) (n int, err error) { 138 if t.buf == nil { 139 t.buf = &bytes.Buffer{} 140 t.outFiles = append(t.outFiles, file{t.prefix, t.fallback, t.buf, false}) 141 } 142 return t.buf.Write(b) 143 } 144 145 type file struct { 146 name string 147 fallback string 148 buf *bytes.Buffer 149 diff bool // true if this contains a diff between fallback and main 150 } 151 152 // bytes returns the bytes in the file's buffer, and ensures that the 153 // slice finishes with a newline (\n). txtar archives cannot contain 154 // files without a final newline. Consequently, when comparing 155 // proposed/generated file content with content from an archive's 156 // file, we must ensure that the proposed content also finishes with a 157 // newline. 158 func (f *file) bytes() []byte { 159 bs := f.buf.Bytes() 160 if l := len(bs); l > 0 && bs[l-1] != '\n' { 161 bs = append(bs, '\n') 162 } 163 return bs 164 } 165 166 // HasTag reports whether the tag with the given key is defined 167 // for the current test. A tag x is defined by a line in the comment 168 // section of the txtar file like: 169 // 170 // #x 171 func (t *Test) HasTag(key string) bool { 172 prefix := []byte("#" + key) 173 s := bufio.NewScanner(bytes.NewReader(t.Archive.Comment)) 174 for s.Scan() { 175 b := s.Bytes() 176 if bytes.Equal(bytes.TrimSpace(b), prefix) { 177 return true 178 } 179 } 180 return false 181 } 182 183 // Value returns the value for the given key for this test and 184 // reports whether it was defined. 185 // 186 // A value is defined by a line in the comment section of the txtar 187 // file like: 188 // 189 // #key: value 190 // 191 // White space is trimmed from the value before returning. 192 func (t *Test) Value(key string) (value string, ok bool) { 193 prefix := []byte("#" + key + ":") 194 s := bufio.NewScanner(bytes.NewReader(t.Archive.Comment)) 195 for s.Scan() { 196 b := s.Bytes() 197 if bytes.HasPrefix(b, prefix) { 198 return string(bytes.TrimSpace(b[len(prefix):])), true 199 } 200 } 201 return "", false 202 } 203 204 // Bool searches for a line starting with #key: value in the comment and 205 // reports whether the key exists and its value is true. 206 func (t *Test) Bool(key string) bool { 207 s, ok := t.Value(key) 208 return ok && s == "true" 209 } 210 211 // Rel converts filename to a normalized form so that it will given the same 212 // output across different runs and OSes. 213 func (t *Test) Rel(filename string) string { 214 rel, err := filepath.Rel(t.Dir, filename) 215 if err != nil { 216 return filepath.Base(filename) 217 } 218 return filepath.ToSlash(rel) 219 } 220 221 // WriteErrors writes the full list of errors in err to the test output. 222 func (t *Test) WriteErrors(err errors.Error) { 223 if err != nil { 224 errors.Print(t, err, &errors.Config{ 225 Cwd: t.Dir, 226 ToSlash: true, 227 }) 228 } 229 } 230 231 // WriteFile formats f and writes it to the main output, 232 // prefixed by a line of the form: 233 // 234 // == name 235 // 236 // where name is the base name of f.Filename. 237 func (t *Test) WriteFile(f *ast.File) { 238 // TODO: use FileWriter instead in separate CL. 239 fmt.Fprintln(t, "==", filepath.Base(f.Filename)) 240 _, _ = t.Write(formatNode(t.T, f)) 241 } 242 243 // Writer returns a Writer with the given name. Data written will 244 // be checked against the file with name "out/\(testName)/\(name)" 245 // in the txtar file. If name is empty, data will be written to the test 246 // output and checked against "out/\(testName)". 247 func (t *Test) Writer(name string) io.Writer { 248 var fallback string 249 switch name { 250 case "": 251 name = t.prefix 252 fallback = t.fallback 253 default: 254 fallback = path.Join(t.fallback, name) 255 name = path.Join(t.prefix, name) 256 } 257 258 for _, f := range t.outFiles { 259 if f.name == name { 260 return f.buf 261 } 262 } 263 264 w := &bytes.Buffer{} 265 t.outFiles = append(t.outFiles, file{name, fallback, w, false}) 266 267 if name == t.prefix { 268 t.buf = w 269 } 270 271 return w 272 } 273 274 func formatNode(t *testing.T, n ast.Node) []byte { 275 t.Helper() 276 277 b, err := format.Node(n) 278 if err != nil { 279 t.Fatal(err) 280 } 281 return b 282 } 283 284 // Instance returns the single instance representing the 285 // root directory in the txtar file. 286 func (t *Test) Instance() *build.Instance { 287 t.Helper() 288 return t.Instances()[0] 289 } 290 291 // Instances returns the valid instances for this .txtar file or skips the 292 // test if there is an error loading the instances. 293 func (t *Test) Instances(args ...string) []*build.Instance { 294 t.Helper() 295 296 a := t.RawInstances(args...) 297 for _, i := range a { 298 if i.Err != nil { 299 if t.hasGold { 300 t.Fatal("Parse error: ", errors.Details(i.Err, nil)) 301 } 302 t.Skip("Parse error: ", errors.Details(i.Err, nil)) 303 } 304 } 305 return a 306 } 307 308 // RawInstances returns the intstances represented by this .txtar file. The 309 // returned instances are not checked for errors. 310 func (t *Test) RawInstances(args ...string) []*build.Instance { 311 return loadWithConfig(t.Archive, t.Dir, t.LoadConfig, args...) 312 } 313 314 // Load loads the intstances of a txtar file. By default, it only loads 315 // files in the root directory. Relative files in the archive are given an 316 // absolute location by prefixing it with dir. 317 func Load(a *txtar.Archive, dir string, args ...string) []*build.Instance { 318 // Don't let Env be nil, as the tests shouldn't depend on os.Environ. 319 return loadWithConfig(a, dir, load.Config{Env: []string{}}, args...) 320 } 321 322 func loadWithConfig(a *txtar.Archive, dir string, cfg load.Config, args ...string) []*build.Instance { 323 auto := len(args) == 0 324 overlay := map[string]load.Source{} 325 for _, f := range a.Files { 326 if auto && !strings.Contains(f.Name, "/") { 327 args = append(args, f.Name) 328 } 329 overlay[filepath.Join(dir, f.Name)] = load.FromBytes(f.Data) 330 } 331 332 cfg.Dir = dir 333 cfg.Overlay = overlay 334 335 return load.Instances(args, &cfg) 336 } 337 338 // Run runs tests defined in txtar files in x.Root or its subdirectories. 339 // 340 // The function f is called for each such txtar file. See the [Test] documentation 341 // for more details. 342 func (x *TxTarTest) Run(t *testing.T, f func(tc *Test)) { 343 if x.Matrix == nil { 344 x.run(t, nil, f) 345 return 346 } 347 x.Matrix.Do(t, func(t *testing.T, m *cuetdtest.M) { 348 test := *x 349 if s := m.Fallback(); s != "" { 350 test.Fallback = test.Name 351 if s != cuetdtest.DefaultVersion { 352 test.Fallback += "-" + s 353 } 354 } 355 if s := m.Name(); s != cuetdtest.DefaultVersion { 356 test.Name += "-" + s 357 } 358 test.run(t, m, func(tc *Test) { 359 f(tc) 360 }) 361 }) 362 } 363 364 // Runtime returns a new runtime based on the configuration of the test. 365 func (t *Test) Runtime() *runtime.Runtime { 366 return (*runtime.Runtime)(t.CueContext()) 367 } 368 369 // CueContext returns a new cue.CueContext based on the configuration of the test. 370 func (t *Test) CueContext() *cue.Context { 371 if t.M != nil { 372 return t.M.CueContext() 373 } 374 return cuecontext.New() 375 } 376 377 func (x *TxTarTest) run(t *testing.T, m *cuetdtest.M, f func(tc *Test)) { 378 t.Helper() 379 380 if x.DebugArchive != "" { 381 archive := txtar.Parse([]byte(x.DebugArchive)) 382 383 t.Run("", func(t *testing.T) { 384 if len(archive.Files) == 0 { 385 t.Fatal("DebugArchive contained no files") 386 } 387 tc := &Test{ 388 T: t, 389 M: m, 390 Archive: archive, 391 Dir: "/tmp", 392 393 prefix: path.Join("out", x.Name), 394 LoadConfig: x.LoadConfig, 395 } 396 // Don't let Env be nil, as the tests shouldn't depend on os.Environ. 397 if tc.LoadConfig.Env == nil { 398 tc.LoadConfig.Env = []string{} 399 } 400 401 f(tc) 402 403 // Unconditionally log the output and fail. 404 t.Log(tc.buf.String()) 405 t.Error("DebugArchive tests always fail") 406 }) 407 return 408 } 409 410 dir, err := os.Getwd() 411 if err != nil { 412 t.Fatal(err) 413 } 414 415 root := x.Root 416 417 err = filepath.WalkDir(root, func(fullpath string, entry fs.DirEntry, err error) error { 418 if err != nil { 419 return err 420 } 421 if entry.IsDir() || filepath.Ext(fullpath) != ".txtar" { 422 return nil 423 } 424 425 str := filepath.ToSlash(fullpath) 426 p := strings.Index(str, "/testdata/") 427 var testName string 428 // Do not include the name of the test if the Matrix feature is not used 429 // to ensure that the todo lists of existing tests do not break. 430 if x.Matrix != nil && x.Name != "" { 431 testName = x.Name + "/" 432 } 433 testName += str[p+len("/testdata/") : len(str)-len(".txtar")] 434 435 t.Run(testName, func(t *testing.T) { 436 a, err := txtar.ParseFile(fullpath) 437 if err != nil { 438 t.Fatalf("error parsing txtar file: %v", err) 439 } 440 441 tc := &Test{ 442 T: t, 443 M: m, 444 Archive: a, 445 Dir: filepath.Dir(filepath.Join(dir, fullpath)), 446 447 prefix: path.Join("out", x.Name), 448 LoadConfig: x.LoadConfig, 449 } 450 // Don't let Env be nil, as the tests shouldn't depend on os.Environ. 451 if tc.LoadConfig.Env == nil { 452 tc.LoadConfig.Env = []string{} 453 } 454 if x.Fallback != "" { 455 tc.fallback = path.Join("out", x.Fallback) 456 } else { 457 tc.fallback = tc.prefix 458 } 459 460 if tc.HasTag("skip") { 461 t.Skip() 462 } 463 if tc.M != nil { 464 // When running via [cuetdtest.Matrix], support e.g. #skip-v2. 465 if tc.HasTag("skip-" + tc.Name()) { 466 t.Skip() 467 } 468 } else if tc.HasTag("skip-v2") && strings.Contains(t.Name(), "EvalV2") { 469 // Temporary hack since internal/core/adt uses TestEvalV2 rather than [cuetdtest.Matrix]. 470 // TODO(mvdan): clean this up. 471 t.Skip() 472 } 473 if msg, ok := x.Skip[testName]; ok { 474 t.Skip(msg) 475 } 476 if msg, ok := x.ToDo[testName]; ok { 477 t.Skip(msg) 478 } 479 480 update := false 481 482 for i, f := range a.Files { 483 hasPrefix := func(s string) bool { 484 // It's either "\(tc.prefix)" or "\(tc.prefix)/..." but not some other name 485 // that happens to start with tc.prefix. 486 return strings.HasPrefix(f.Name, s) && (f.Name == s || f.Name[len(s)] == '/') 487 } 488 489 tc.hasGold = hasPrefix(tc.prefix) || hasPrefix(tc.fallback) 490 491 // Format CUE files as required 492 if tc.HasTag("noformat") || !strings.HasSuffix(f.Name, ".cue") { 493 continue 494 } 495 if ff, err := format.Source(f.Data); err == nil { 496 if bytes.Equal(f.Data, ff) { 497 continue 498 } 499 if cuetest.FormatTxtar { 500 update = true 501 a.Files[i].Data = ff 502 } 503 } 504 } 505 f(tc) 506 507 // Track the position of the fallback files. 508 index := make(map[string]int, len(a.Files)) 509 for i, f := range a.Files { 510 if _, ok := index[f.Name]; ok { 511 t.Errorf("duplicated txtar file entry %s", f.Name) 512 } 513 index[f.Name] = i 514 } 515 516 // Record ordering of files in the archive to preserve that ordering 517 // later. 518 ordering := maps.Clone(index) 519 520 // Add diff files between fallback and main file. These are added 521 // as regular output files so that they can be updated as well. 522 for _, sub := range tc.outFiles { 523 if sub.fallback == sub.name { 524 continue 525 } 526 if j, ok := index[sub.fallback]; ok { 527 if _, ok := ordering[sub.name]; !ok { 528 ordering[sub.name] = j 529 } 530 fallback := a.Files[j].Data 531 532 result := sub.bytes() 533 if len(result) == 0 || len(fallback) == 0 { 534 continue 535 } 536 537 diffName := "diff/-" + sub.name + "<==>+" + sub.fallback 538 if _, ok := ordering[diffName]; !ok { 539 ordering[diffName] = j 540 } 541 switch diff := diff.Diff("old", fallback, "new", result); { 542 case len(diff) > 0: 543 tc.outFiles = append(tc.outFiles, file{ 544 name: diffName, 545 buf: bytes.NewBuffer(diff), 546 diff: true, 547 }) 548 549 default: 550 // Only update file if anything changes. 551 if _, ok := index[sub.name]; ok { 552 delete(index, sub.name) 553 if !cuetest.UpdateGoldenFiles { 554 t.Errorf("file %q exists but is equal to fallback", sub.name) 555 } 556 update = cuetest.UpdateGoldenFiles 557 } 558 if _, ok := index[diffName]; ok { 559 delete(index, diffName) 560 if !cuetest.UpdateGoldenFiles { 561 t.Errorf("file %q exists but is empty", diffName) 562 } 563 update = cuetest.UpdateGoldenFiles 564 } 565 // Remove all diff-related todo files. 566 for n := range index { 567 if strings.HasPrefix(n, "diff/todo/") { 568 delete(index, n) 569 if !cuetest.UpdateGoldenFiles { 570 t.Errorf("todo file %q exists without changes", n) 571 } 572 update = cuetest.UpdateGoldenFiles 573 } 574 } 575 } 576 } 577 } 578 579 files := make([]txtar.File, 0, len(a.Files)) 580 581 for _, sub := range tc.outFiles { 582 result := sub.bytes() 583 584 files = append(files, txtar.File{Name: sub.name}) 585 gold := &files[len(files)-1] 586 587 if i, ok := index[sub.name]; ok { 588 gold.Data = a.Files[i].Data 589 delete(index, sub.name) 590 591 if bytes.Equal(gold.Data, result) { 592 continue 593 } 594 } else if i, ok := index[sub.fallback]; ok { 595 gold.Data = a.Files[i].Data 596 597 // Use the golden file of the fallback set if it matches. 598 if bytes.Equal(gold.Data, result) { 599 gold.Name = sub.fallback 600 delete(index, sub.fallback) 601 continue 602 } 603 } 604 605 if cuetest.UpdateGoldenFiles { 606 update = true 607 gold.Data = result 608 continue 609 } 610 611 // Skip the test if just the diff differs. 612 // TODO: also fail once diffs are fully in use. 613 if sub.diff { 614 continue 615 } 616 617 t.Errorf("result for %s differs: (-want +got)\n%s", 618 sub.name, 619 cmp.Diff(string(gold.Data), string(result)), 620 ) 621 t.Errorf("actual result: %q", result) 622 } 623 624 // Add remaining unrelated files, ignoring files that were already 625 // added. 626 for _, f := range a.Files { 627 if _, ok := index[f.Name]; ok { 628 files = append(files, f) 629 } 630 } 631 a.Files = files 632 633 if update { 634 slices.SortStableFunc(a.Files, func(i, j txtar.File) int { 635 p, ok := ordering[i.Name] 636 if !ok { 637 p = len(a.Files) 638 } 639 q, ok := ordering[j.Name] 640 if !ok { 641 q = len(a.Files) 642 } 643 return p - q 644 }) 645 646 err = os.WriteFile(fullpath, txtar.Format(a), 0644) 647 if err != nil { 648 t.Fatal(err) 649 } 650 } 651 }) 652 653 return nil 654 }) 655 656 if err != nil { 657 t.Fatal(err) 658 } 659 }