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  }