go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/starlark_test.go (about)

     1  // Copyright 2018 The LUCI 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 lucicfg
    16  
    17  import (
    18  	"bufio"
    19  	"bytes"
    20  	"context"
    21  	"fmt"
    22  	"os"
    23  	"path/filepath"
    24  	"regexp"
    25  	"strings"
    26  	"testing"
    27  
    28  	"github.com/sergi/go-diff/diffmatchpatch"
    29  
    30  	"go.starlark.net/resolve"
    31  	"go.starlark.net/starlark"
    32  
    33  	"go.chromium.org/luci/common/errors"
    34  	"go.chromium.org/luci/starlark/builtins"
    35  	"go.chromium.org/luci/starlark/interpreter"
    36  	"go.chromium.org/luci/starlark/starlarktest"
    37  )
    38  
    39  // If this env var is 1, the test will regenerate the "Expect configs:" part of
    40  // test *.star files.
    41  const RegenEnvVar = "LUCICFG_TEST_REGEN"
    42  
    43  const (
    44  	expectConfigsHeader    = "Expect configs:"
    45  	expectErrorsHeader     = "Expect errors:"
    46  	expectErrorsLikeHeader = "Expect errors like:"
    47  )
    48  
    49  func init() {
    50  	// Enable not-yet-standard features.
    51  	resolve.AllowLambda = true
    52  	resolve.AllowNestedDef = true
    53  	resolve.AllowFloat = true
    54  	resolve.AllowSet = true
    55  }
    56  
    57  // TestAllStarlark loads and executes all test scripts (testdata/*.star).
    58  func TestAllStarlark(t *testing.T) {
    59  	t.Parallel()
    60  
    61  	gotExpectationErrors := false
    62  
    63  	starlarktest.RunTests(t, starlarktest.Options{
    64  		TestsDir: "testdata",
    65  		Skip:     "support",
    66  
    67  		Executor: func(t *testing.T, path string, predeclared starlark.StringDict) error {
    68  			blob, err := os.ReadFile(path)
    69  			if err != nil {
    70  				return err
    71  			}
    72  			body := string(blob)
    73  
    74  			// Read "mocked" `-var name=value` assignments.
    75  			presetVars := map[string]string{}
    76  			presetVarsBlock := readCommentBlock(body, "Prepare CLI vars as:")
    77  			for _, line := range strings.Split(presetVarsBlock, "\n") {
    78  				if line = strings.TrimSpace(line); line != "" {
    79  					chunks := strings.SplitN(line, "=", 2)
    80  					if len(chunks) != 2 {
    81  						t.Errorf("Bad CLI var declaration %q", line)
    82  						return nil
    83  					}
    84  					presetVars[chunks[0]] = chunks[1]
    85  				}
    86  			}
    87  
    88  			expectErrExct := readCommentBlock(body, expectErrorsHeader)
    89  			expectErrLike := readCommentBlock(body, expectErrorsLikeHeader)
    90  			expectCfg := readCommentBlock(body, expectConfigsHeader)
    91  			if expectErrExct != "" && expectErrLike != "" {
    92  				t.Errorf("Cannot use %q and %q at the same time", expectErrorsHeader, expectErrorsLikeHeader)
    93  				return nil
    94  			}
    95  
    96  			// We treat tests that compare the generator output to some expected
    97  			// output as "integration tests", and everything else is a unit tests.
    98  			// See below for why this is important.
    99  			integrationTest := expectErrExct != "" || expectErrLike != "" || expectCfg != ""
   100  
   101  			state, err := Generate(context.Background(), Inputs{
   102  				// Use file system loader so test scripts can load supporting scripts
   103  				// (from '**/support/*' which is skipped by the test runner). This also
   104  				// makes error messages have the original scripts full name. Note that
   105  				// 'go test' executes tests with cwd set to corresponding package
   106  				// directories, regardless of what cwd was when 'go test' was called.
   107  				Code:  interpreter.FileSystemLoader("."),
   108  				Entry: filepath.ToSlash(path),
   109  				Vars:  presetVars,
   110  
   111  				// Expose 'assert' module, hook up error reporting to 't'.
   112  				testPredeclared: predeclared,
   113  				testThreadModifier: func(th *starlark.Thread) {
   114  					starlarktest.HookThread(th, t)
   115  				},
   116  
   117  				// Don't spit out "# This file is generated by lucicfg" headers.
   118  				testOmitHeader: true,
   119  
   120  				// Failure collector interferes with assert.fails() in a bad way.
   121  				// assert.fails() captures errors, but it doesn't clear the failure
   122  				// collector state, so we may end up in a situation when the script
   123  				// fails with one error (some native starlark error, e.g. invalid
   124  				// function call, not 'fail'), but the failure collector remembers
   125  				// another (stale!) error, emitted by 'fail' before and caught by
   126  				// assert.fails(). This results in invalid error message at the end
   127  				// of the script execution.
   128  				//
   129  				// Unfortunately, it is not easy to modify assert.fails() without
   130  				// forking it. So instead we do a cheesy thing and disable the failure
   131  				// collector if the file under test appears to be unit-testy (rather
   132  				// than integration-testy). We define integration tests to be tests
   133  				// that examine the output of the generator using "Expect ..." blocks
   134  				// (see above), and unit tests are tests that use asserts.
   135  				//
   136  				// Disabling the failure collector results in fail(..., trace=t)
   137  				// ignoring the custom stack trace 't'. But unit tests don't generally
   138  				// check the stack trace (only the error message), so it's not a big
   139  				// deal for them.
   140  				testDisableFailureCollector: !integrationTest,
   141  
   142  				// Do not put frequently changing version string into test outputs.
   143  				testVersion: "1.1.1",
   144  			})
   145  
   146  			// If test was expected to fail on Starlark side, make sure it did, in
   147  			// an expected way.
   148  			if expectErrExct != "" || expectErrLike != "" {
   149  				allErrs := strings.Builder{}
   150  				var skip bool
   151  				errors.Walk(err, func(err error) bool {
   152  					if skip {
   153  						skip = false
   154  						return true
   155  					}
   156  
   157  					if bt, ok := err.(BacktracableError); ok {
   158  						allErrs.WriteString(bt.Backtrace())
   159  						// We need to skip Unwrap from starlark.EvalError
   160  						_, skip = err.(*starlark.EvalError)
   161  					} else {
   162  						switch err.(type) {
   163  						case errors.MultiError, errors.Wrapped:
   164  							return true
   165  						}
   166  
   167  						allErrs.WriteString(err.Error())
   168  					}
   169  					allErrs.WriteString("\n\n")
   170  					return true
   171  				})
   172  
   173  				// Strip line and column numbers from backtraces.
   174  				normalized := builtins.NormalizeStacktrace(allErrs.String())
   175  
   176  				if expectErrExct != "" {
   177  					errorOnDiff(t, normalized, expectErrExct)
   178  				} else {
   179  					errorOnPatternMismatch(t, normalized, expectErrLike)
   180  				}
   181  				return nil
   182  			}
   183  
   184  			// Otherwise just report all errors to Mr. T.
   185  			errors.WalkLeaves(err, func(err error) bool {
   186  				if bt, ok := err.(BacktracableError); ok {
   187  					t.Errorf("%s\n", bt.Backtrace())
   188  				} else {
   189  					t.Errorf("%s\n", err)
   190  				}
   191  				return true
   192  			})
   193  			if err != nil {
   194  				return nil // the error has been reported already
   195  			}
   196  
   197  			// If was expecting to see some configs, assert we did see them.
   198  			if expectCfg != "" {
   199  				got := bytes.Buffer{}
   200  				for idx, f := range state.Output.Files() {
   201  					if idx != 0 {
   202  						fmt.Fprintf(&got, "\n\n")
   203  					}
   204  					fmt.Fprintf(&got, "=== %s\n", f)
   205  					if blob, err := state.Output.Data[f].Bytes(); err != nil {
   206  						t.Errorf("Serializing %s: %s", f, err)
   207  					} else {
   208  						fmt.Fprintf(&got, string(blob))
   209  					}
   210  					fmt.Fprintf(&got, "===")
   211  				}
   212  				if os.Getenv(RegenEnvVar) == "1" {
   213  					if err := updateExpected(path, got.String()); err != nil {
   214  						t.Errorf("Failed to updated %q: %s", path, err)
   215  					}
   216  				} else if errorOnDiff(t, got.String(), expectCfg) {
   217  					gotExpectationErrors = true
   218  				}
   219  			}
   220  
   221  			return nil
   222  		},
   223  	})
   224  
   225  	if gotExpectationErrors {
   226  		t.Errorf("\n\n"+
   227  			"========================================================\n"+
   228  			"If you want to update expectations stored in *.star run:\n"+
   229  			"$ %s=1 go test .\n"+
   230  			"========================================================", RegenEnvVar)
   231  	}
   232  }
   233  
   234  // readCommentBlock reads a comment block that start with "# <hdr>\n".
   235  //
   236  // Returns empty string if there's no such block.
   237  func readCommentBlock(script, hdr string) string {
   238  	scanner := bufio.NewScanner(strings.NewReader(script))
   239  	for scanner.Scan() && scanner.Text() != "# "+hdr {
   240  		continue
   241  	}
   242  	sb := strings.Builder{}
   243  	for scanner.Scan() {
   244  		if line := scanner.Text(); strings.HasPrefix(line, "#") {
   245  			sb.WriteString(strings.TrimPrefix(line[1:], " "))
   246  			sb.WriteRune('\n')
   247  		} else {
   248  			break // the comment block has ended
   249  		}
   250  	}
   251  	return sb.String()
   252  }
   253  
   254  // updateExpected updates the expected generated config stored in the comment
   255  // block at the end of the *.star file.
   256  func updateExpected(path, exp string) error {
   257  	blob, err := os.ReadFile(path)
   258  	if err != nil {
   259  		return err
   260  	}
   261  
   262  	idx := bytes.Index(blob, []byte(fmt.Sprintf("# %s\n", expectConfigsHeader)))
   263  	if idx == -1 {
   264  		return errors.Reason("doesn't have `Expect configs` comment block").Err()
   265  	}
   266  	blob = blob[:idx]
   267  
   268  	blob = append(blob, []byte(fmt.Sprintf("# %s\n", expectConfigsHeader))...)
   269  	blob = append(blob, []byte("#\n")...)
   270  	for _, line := range strings.Split(exp, "\n") {
   271  		if len(line) == 0 {
   272  			blob = append(blob, '#')
   273  		} else {
   274  			blob = append(blob, []byte("# ")...)
   275  			blob = append(blob, []byte(line)...)
   276  		}
   277  		blob = append(blob, '\n')
   278  	}
   279  
   280  	return os.WriteFile(path, blob, 0666)
   281  }
   282  
   283  // errorOnDiff emits an error to T and returns true if got != exp.
   284  func errorOnDiff(t *testing.T, got, exp string) bool {
   285  	t.Helper()
   286  
   287  	got = strings.TrimSpace(got)
   288  	exp = strings.TrimSpace(exp)
   289  
   290  	switch {
   291  	case got == "":
   292  		t.Errorf("Got nothing, but was expecting:\n\n%s\n", exp)
   293  		return true
   294  	case got != exp:
   295  		dmp := diffmatchpatch.New()
   296  		diffs := dmp.DiffMain(exp, got, false)
   297  		t.Errorf(
   298  			"Got:\n\n%s\n\nWas expecting:\n\n%s\n\nDiff:\n\n%s\n",
   299  			got, exp, dmp.DiffPrettyText(diffs))
   300  		return true
   301  	}
   302  
   303  	return false
   304  }
   305  
   306  // errorOnMismatch emits an error to T if got doesn't match a pattern pat.
   307  //
   308  // The pattern is syntax is:
   309  //   - A line "[space]...[space]" matches zero or more arbitrary lines.
   310  //   - Trigram "???" matches [0-9a-zA-Z]+.
   311  //   - The rest should match as is.
   312  func errorOnPatternMismatch(t *testing.T, got, pat string) {
   313  	t.Helper()
   314  
   315  	got = strings.TrimSpace(got)
   316  	pat = strings.TrimSpace(pat)
   317  
   318  	re := strings.Builder{}
   319  	re.WriteRune('^')
   320  	for _, line := range strings.Split(pat, "\n") {
   321  		if strings.TrimSpace(line) == "..." {
   322  			re.WriteString(`(.*\n)*`)
   323  		} else {
   324  			for line != "" {
   325  				idx := strings.Index(line, "???")
   326  				if idx == -1 {
   327  					re.WriteString(regexp.QuoteMeta(line))
   328  					break
   329  				}
   330  				re.WriteString(regexp.QuoteMeta(line[:idx]))
   331  				re.WriteString(`[0-9a-zA-Z]+`)
   332  				line = line[idx+3:]
   333  			}
   334  			re.WriteString(`\n`)
   335  		}
   336  	}
   337  	re.WriteRune('$')
   338  
   339  	if exp := regexp.MustCompile(re.String()); !exp.MatchString(got + "\n") {
   340  		t.Errorf("Got:\n\n%s\n\nWas expecting pattern:\n\n%s\n\n", got, pat)
   341  		t.Errorf("Regexp: %s", re.String())
   342  	}
   343  }