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  }