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 }