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