blob: cdb5a0db12579a68cc8c03856007fa79b21f4bb3 [file] [log] [blame]
// Copyright 2018 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package lucicfg
import (
"bufio"
"bytes"
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/sergi/go-diff/diffmatchpatch"
"go.starlark.net/resolve"
"go.starlark.net/starlark"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/starlark/builtins"
"go.chromium.org/luci/starlark/interpreter"
"go.chromium.org/luci/starlark/starlarktest"
)
// If this env var is 1, the test will regenerate the "Expect configs:" part of
// test *.star files.
const RegenEnvVar = "LUCICFG_TEST_REGEN"
const (
expectConfigsHeader = "Expect configs:"
expectErrorsHeader = "Expect errors:"
expectErrorsLikeHeader = "Expect errors like:"
)
func init() {
// Enable not-yet-standard features.
resolve.AllowLambda = true
resolve.AllowNestedDef = true
resolve.AllowFloat = true
resolve.AllowSet = true
}
// TestAllStarlark loads and executes all test scripts (testdata/*.star).
func TestAllStarlark(t *testing.T) {
t.Parallel()
gotExpectationErrors := false
starlarktest.RunTests(t, starlarktest.Options{
TestsDir: "testdata",
Skip: "support",
Executor: func(t *testing.T, path string, predeclared starlark.StringDict) error {
blob, err := ioutil.ReadFile(path)
if err != nil {
return err
}
body := string(blob)
// Read "mocked" `-var name=value` assignments.
presetVars := map[string]string{}
presetVarsBlock := readCommentBlock(body, "Prepare CLI vars as:")
for _, line := range strings.Split(presetVarsBlock, "\n") {
if line = strings.TrimSpace(line); line != "" {
chunks := strings.SplitN(line, "=", 2)
if len(chunks) != 2 {
t.Errorf("Bad CLI var declaration %q", line)
return nil
}
presetVars[chunks[0]] = chunks[1]
}
}
expectErrExct := readCommentBlock(body, expectErrorsHeader)
expectErrLike := readCommentBlock(body, expectErrorsLikeHeader)
expectCfg := readCommentBlock(body, expectConfigsHeader)
if expectErrExct != "" && expectErrLike != "" {
t.Errorf("Cannot use %q and %q at the same time", expectErrorsHeader, expectErrorsLikeHeader)
return nil
}
// We treat tests that compare the generator output to some expected
// output as "integration tests", and everything else is a unit tests.
// See below for why this is important.
integrationTest := expectErrExct != "" || expectErrLike != "" || expectCfg != ""
state, err := Generate(context.Background(), Inputs{
// Use file system loader so test scripts can load supporting scripts
// (from '**/support/*' which is skipped by the test runner). This also
// makes error messages have the original scripts full name. Note that
// 'go test' executes tests with cwd set to corresponding package
// directories, regardless of what cwd was when 'go test' was called.
Code: interpreter.FileSystemLoader("."),
Entry: filepath.ToSlash(path),
Vars: presetVars,
// Expose 'assert' module, hook up error reporting to 't'.
testPredeclared: predeclared,
testThreadModifier: func(th *starlark.Thread) {
starlarktest.HookThread(th, t)
},
// Don't spit out "# This file is generated by lucicfg" headers.
testOmitHeader: true,
// Failure collector interferes with assert.fails() in a bad way.
// assert.fails() captures errors, but it doesn't clear the failure
// collector state, so we may end up in a situation when the script
// fails with one error (some native starlark error, e.g. invalid
// function call, not 'fail'), but the failure collector remembers
// another (stale!) error, emitted by 'fail' before and caught by
// assert.fails(). This results in invalid error message at the end
// of the script execution.
//
// Unfortunately, it is not easy to modify assert.fails() without
// forking it. So instead we do a cheesy thing and disable the failure
// collector if the file under test appears to be unit-testy (rather
// than integration-testy). We define integration tests to be tests
// that examine the output of the generator using "Expect ..." blocks
// (see above), and unit tests are tests that use asserts.
//
// Disabling the failure collector results in fail(..., trace=t)
// ignoring the custom stack trace 't'. But unit tests don't generally
// check the stack trace (only the error message), so it's not a big
// deal for them.
testDisableFailureCollector: !integrationTest,
// Do not put frequently changing version string into test outputs.
testVersion: "1.1.1",
})
// If test was expected to fail on Starlark side, make sure it did, in
// an expected way.
if expectErrExct != "" || expectErrLike != "" {
allErrs := strings.Builder{}
var skip bool
errors.Walk(err, func(err error) bool {
if skip {
skip = false
return true
}
if bt, ok := err.(BacktracableError); ok {
allErrs.WriteString(bt.Backtrace())
// We need to skip Unwrap from starlark.EvalError
_, skip = err.(*starlark.EvalError)
} else {
switch err.(type) {
case errors.MultiError, errors.Wrapped:
return true
}
allErrs.WriteString(err.Error())
}
allErrs.WriteString("\n\n")
return true
})
// Strip line and column numbers from backtraces.
normalized := builtins.NormalizeStacktrace(allErrs.String())
if expectErrExct != "" {
errorOnDiff(t, normalized, expectErrExct)
} else {
errorOnPatternMismatch(t, normalized, expectErrLike)
}
return nil
}
// Otherwise just report all errors to Mr. T.
errors.WalkLeaves(err, func(err error) bool {
if bt, ok := err.(BacktracableError); ok {
t.Errorf("%s\n", bt.Backtrace())
} else {
t.Errorf("%s\n", err)
}
return true
})
if err != nil {
return nil // the error has been reported already
}
// If was expecting to see some configs, assert we did see them.
if expectCfg != "" {
got := bytes.Buffer{}
for idx, f := range state.Output.Files() {
if idx != 0 {
fmt.Fprintf(&got, "\n\n")
}
fmt.Fprintf(&got, "=== %s\n", f)
if blob, err := state.Output.Data[f].Bytes(); err != nil {
t.Errorf("Serializing %s: %s", f, err)
} else {
fmt.Fprintf(&got, string(blob))
}
fmt.Fprintf(&got, "===")
}
if os.Getenv(RegenEnvVar) == "1" {
if err := updateExpected(path, got.String()); err != nil {
t.Errorf("Failed to updated %q: %s", path, err)
}
} else if errorOnDiff(t, got.String(), expectCfg) {
gotExpectationErrors = true
}
}
return nil
},
})
if gotExpectationErrors {
t.Errorf("\n\n"+
"========================================================\n"+
"If you want to update expectations stored in *.star run:\n"+
"$ %s=1 go test .\n"+
"========================================================", RegenEnvVar)
}
}
// readCommentBlock reads a comment block that start with "# <hdr>\n".
//
// Returns empty string if there's no such block.
func readCommentBlock(script, hdr string) string {
scanner := bufio.NewScanner(strings.NewReader(script))
for scanner.Scan() && scanner.Text() != "# "+hdr {
continue
}
sb := strings.Builder{}
for scanner.Scan() {
if line := scanner.Text(); strings.HasPrefix(line, "#") {
sb.WriteString(strings.TrimPrefix(line[1:], " "))
sb.WriteRune('\n')
} else {
break // the comment block has ended
}
}
return sb.String()
}
// updateExpected updates the expected generated config stored in the comment
// block at the end of the *.star file.
func updateExpected(path, exp string) error {
blob, err := ioutil.ReadFile(path)
if err != nil {
return err
}
idx := bytes.Index(blob, []byte(fmt.Sprintf("# %s\n", expectConfigsHeader)))
if idx == -1 {
return errors.Reason("doesn't have `Expect configs` comment block").Err()
}
blob = blob[:idx]
blob = append(blob, []byte(fmt.Sprintf("# %s\n", expectConfigsHeader))...)
blob = append(blob, []byte("#\n")...)
for _, line := range strings.Split(exp, "\n") {
if len(line) == 0 {
blob = append(blob, '#')
} else {
blob = append(blob, []byte("# ")...)
blob = append(blob, []byte(line)...)
}
blob = append(blob, '\n')
}
return ioutil.WriteFile(path, blob, 0666)
}
// errorOnDiff emits an error to T and returns true if got != exp.
func errorOnDiff(t *testing.T, got, exp string) bool {
t.Helper()
got = strings.TrimSpace(got)
exp = strings.TrimSpace(exp)
switch {
case got == "":
t.Errorf("Got nothing, but was expecting:\n\n%s\n", exp)
return true
case got != exp:
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(exp, got, false)
t.Errorf(
"Got:\n\n%s\n\nWas expecting:\n\n%s\n\nDiff:\n\n%s\n",
got, exp, dmp.DiffPrettyText(diffs))
return true
}
return false
}
// errorOnMismatch emits an error to T if got doesn't match a pattern pat.
//
// The pattern is syntax is:
// * A line "[space]...[space]" matches zero or more arbitrary lines.
// * Trigram "???" matches [0-9a-zA-Z]+.
// * The rest should match as is.
func errorOnPatternMismatch(t *testing.T, got, pat string) {
t.Helper()
got = strings.TrimSpace(got)
pat = strings.TrimSpace(pat)
re := strings.Builder{}
re.WriteRune('^')
for _, line := range strings.Split(pat, "\n") {
if strings.TrimSpace(line) == "..." {
re.WriteString(`(.*\n)*`)
} else {
for line != "" {
idx := strings.Index(line, "???")
if idx == -1 {
re.WriteString(regexp.QuoteMeta(line))
break
}
re.WriteString(regexp.QuoteMeta(line[:idx]))
re.WriteString(`[0-9a-zA-Z]+`)
line = line[idx+3:]
}
re.WriteString(`\n`)
}
}
re.WriteRune('$')
if exp := regexp.MustCompile(re.String()); !exp.MatchString(got + "\n") {
t.Errorf("Got:\n\n%s\n\nWas expecting pattern:\n\n%s\n\n", got, pat)
t.Errorf("Regexp: %s", re.String())
}
}