go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luciexe/legacy/annotee/annotation/annotation_test.go (about) 1 // Copyright 2015 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 annotation 16 17 import ( 18 "bufio" 19 "flag" 20 "fmt" 21 "os" 22 "path/filepath" 23 "sort" 24 "strings" 25 "testing" 26 "time" 27 "unicode" 28 29 "github.com/golang/protobuf/proto" 30 "go.chromium.org/luci/common/clock/testclock" 31 "go.chromium.org/luci/common/data/stringset" 32 "go.chromium.org/luci/common/errors" 33 "go.chromium.org/luci/logdog/common/types" 34 annopb "go.chromium.org/luci/luciexe/legacy/annotee/proto" 35 36 . "github.com/smartystreets/goconvey/convey" 37 . "go.chromium.org/luci/common/testing/assertions" 38 ) 39 40 const testDataDir = "test_data" 41 const testExpDir = "test_expectations" 42 43 var generate = flag.Bool("annotee.generate", false, "If true, regenerate expectations from source.") 44 45 type testCase struct { 46 name string 47 exe *Execution 48 } 49 50 func (tc *testCase) state(startTime time.Time) *State { 51 cb := testCallbacks{ 52 closed: map[*Step]struct{}{}, 53 logs: map[types.StreamName][]string{}, 54 logsOpen: map[types.StreamName]struct{}{}, 55 } 56 return &State{ 57 LogNameBase: types.StreamName("base"), 58 Callbacks: &cb, 59 Clock: testclock.New(startTime), 60 Execution: tc.exe, 61 } 62 } 63 64 func (tc *testCase) generate(t *testing.T, startTime time.Time, touched stringset.Set) error { 65 st := tc.state(startTime) 66 p, err := playAnnotationScript(t, tc.name, st) 67 if err != nil { 68 return err 69 } 70 touched.Add(p) 71 st.Finish() 72 73 // Write out generated protos. 74 merr := errors.MultiError(nil) 75 76 step := st.RootStep() 77 p, err = writeStepProto(tc.name, step) 78 if err != nil { 79 merr = append(merr, fmt.Errorf("Failed to write step proto for %q::%q: %v", tc.name, step.LogNameBase, err)) 80 } 81 touched.Add(p) 82 83 // Write out generated logs. 84 cb := st.Callbacks.(*testCallbacks) 85 for logName, lines := range cb.logs { 86 p, err := writeLogText(tc.name, string(logName), lines) 87 if err != nil { 88 merr = append(merr, fmt.Errorf("Failed to write log text for %q::%q: %v", tc.name, logName, err)) 89 } 90 touched.Add(p) 91 } 92 93 if merr != nil { 94 return merr 95 } 96 return nil 97 } 98 99 func normalize(s string) string { 100 return strings.Map(func(r rune) rune { 101 if r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) { 102 return r 103 } 104 return '_' 105 }, s) 106 } 107 108 func superfluous(touched stringset.Set) ([]string, error) { 109 var paths []string 110 111 files, err := os.ReadDir(testExpDir) 112 if err != nil { 113 return nil, fmt.Errorf("failed to read directory %q: %v", testExpDir, err) 114 } 115 116 for _, f := range files { 117 if f.IsDir() { 118 continue 119 } 120 121 path := filepath.Join(testExpDir, f.Name()) 122 if !touched.Has(path) { 123 paths = append(paths, path) 124 } 125 } 126 return paths, nil 127 } 128 129 // playAnnotationScript loads named annotation script and plays it 130 // through the supplied State line-by-line. Returns path to the annotation 131 // script. 132 // 133 // Empty lines and lines beginning with "#" are ignored. Preceding whitespace 134 // is discarded. 135 func playAnnotationScript(t *testing.T, name string, st *State) (string, error) { 136 tc := st.Clock.(testclock.TestClock) 137 138 path := filepath.Join(testDataDir, fmt.Sprintf("%s.annotations.txt", normalize(name))) 139 f, err := os.Open(path) 140 if err != nil { 141 t.Errorf("Failed to open annotations source [%s]: %v", path, err) 142 return "", err 143 } 144 defer f.Close() 145 146 scanner := bufio.NewScanner(f) 147 var nextErr string 148 for lineNo := 1; scanner.Scan(); lineNo++ { 149 // Trim, discard empty lines and comment lines. 150 line := strings.TrimLeftFunc(scanner.Text(), unicode.IsSpace) 151 if len(line) == 0 || strings.HasPrefix(line, "#") { 152 continue 153 } 154 155 switch { 156 case line == "+time": 157 tc.Add(1 * time.Second) 158 159 case strings.HasPrefix(line, "+error"): 160 nextErr = strings.SplitN(line, " ", 2)[1] 161 162 default: 163 // Annotation. 164 err := st.Append(line) 165 if nextErr != "" { 166 expectedErr := nextErr 167 nextErr = "" 168 169 if err == nil { 170 return "", fmt.Errorf("line %d: expected error, but didn't encounter it: %q", lineNo, expectedErr) 171 } 172 if !strings.Contains(err.Error(), expectedErr) { 173 return "", fmt.Errorf("line %d: expected error %q, but got: %v", lineNo, expectedErr, err) 174 } 175 } else if err != nil { 176 return "", err 177 } 178 } 179 } 180 181 return path, nil 182 } 183 184 func loadStepProto(t *testing.T, test string, s *Step) *annopb.Step { 185 path := filepath.Join(testExpDir, fmt.Sprintf("%s_%s.proto.txt", normalize(test), normalize(string(s.LogNameBase)))) 186 data, err := os.ReadFile(path) 187 if err != nil { 188 t.Errorf("Failed to read annopb.Step proto [%s]: %v", path, err) 189 return nil 190 } 191 192 st := annopb.Step{} 193 if err := proto.UnmarshalText(string(data), &st); err != nil { 194 t.Errorf("Failed to Unmarshal annopb.Step proto [%s]: %v", path, err) 195 return nil 196 } 197 return &st 198 } 199 200 func writeStepProto(test string, s *Step) (string, error) { 201 path := filepath.Join(testExpDir, fmt.Sprintf("%s_%s.proto.txt", normalize(test), normalize(string(s.LogNameBase)))) 202 return path, os.WriteFile(path, []byte(proto.MarshalTextString(s.Proto())), 0644) 203 } 204 205 func loadLogText(t *testing.T, test, name string) []string { 206 path := filepath.Join(testExpDir, fmt.Sprintf("%s_%s.txt", normalize(test), normalize(name))) 207 f, err := os.Open(path) 208 if err != nil { 209 t.Errorf("Failed to open log lines [%s]: %v", path, err) 210 return nil 211 } 212 defer f.Close() 213 214 lines := []string(nil) 215 scanner := bufio.NewScanner(f) 216 for scanner.Scan() { 217 lines = append(lines, scanner.Text()) 218 } 219 return lines 220 } 221 222 func writeLogText(test, name string, text []string) (string, error) { 223 path := filepath.Join(testExpDir, fmt.Sprintf("%s_%s.txt", normalize(test), normalize(name))) 224 return path, os.WriteFile(path, []byte(strings.Join(text, "\n")), 0644) 225 } 226 227 // testCallbacks implements the Callbacks interface, retaining all callback 228 // data in memory. 229 type testCallbacks struct { 230 // closed is the set of steps that have been closed. 231 closed map[*Step]struct{} 232 233 // logs is the content of emitted annotation logs, keyed on stream name. 234 logs map[types.StreamName][]string 235 // logsOpen tracks whether a given annotation log is open. 236 logsOpen map[types.StreamName]struct{} 237 } 238 239 func (tc *testCallbacks) StepClosed(s *Step) { 240 tc.closed[s] = struct{}{} 241 } 242 243 func (tc *testCallbacks) StepLogLine(s *Step, n types.StreamName, label, line string) { 244 if _, ok := tc.logs[n]; ok { 245 // The log exists. Assert that it is open. 246 if _, ok := tc.logsOpen[n]; !ok { 247 panic(fmt.Errorf("write to closed log stream: %q", n)) 248 } 249 } 250 251 tc.logsOpen[n] = struct{}{} 252 tc.logs[n] = append(tc.logs[n], line) 253 } 254 255 func (tc *testCallbacks) StepLogEnd(s *Step, n types.StreamName) { 256 if _, ok := tc.logsOpen[n]; !ok { 257 panic(fmt.Errorf("close of closed log stream: %q", n)) 258 } 259 delete(tc.logsOpen, n) 260 } 261 262 func (tc *testCallbacks) Updated(s *Step, ut UpdateType) {} 263 264 func TestState(t *testing.T) { 265 t.Parallel() 266 267 startTime := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC) 268 testCases := []testCase{ 269 {"default", &Execution{ 270 Name: "testcommand", 271 Command: []string{"testcommand", "foo", "bar"}, 272 Dir: "/path/to/dir", 273 Env: map[string]string{ 274 "FOO": "BAR", 275 "BAZ": "QUX", 276 }, 277 }}, 278 {"timestamps", nil}, 279 {"coverage", nil}, 280 {"nested", nil}, 281 {"legacy", nil}, 282 } 283 284 if *generate { 285 touched := stringset.New(0) 286 for _, tc := range testCases { 287 if err := tc.generate(t, startTime, touched); err != nil { 288 t.Fatalf("Failed to generate %q: %v\n", tc.name, err) 289 } 290 } 291 292 paths, err := superfluous(touched) 293 if err != nil { 294 if merr, ok := err.(errors.MultiError); ok { 295 for i, ierr := range merr { 296 t.Logf("Error #%d: %s", i, ierr) 297 } 298 } 299 t.Fatalf("Superfluous test data: %v", err) 300 } 301 for _, path := range paths { 302 t.Log("Removing superfluous test data:", path) 303 os.Remove(path) 304 } 305 return 306 } 307 308 Convey(`A testing annotation State`, t, func() { 309 for _, testCase := range testCases { 310 st := testCase.state(startTime) 311 312 Convey(fmt.Sprintf(`Correctly loads/generates for %q test case.`, testCase.name), func() { 313 314 _, err := playAnnotationScript(t, testCase.name, st) 315 So(err, ShouldBeNil) 316 317 // Iterate through generated streams and validate. 318 st.Finish() 319 320 // All log streams should be closed. 321 cb := st.Callbacks.(*testCallbacks) 322 So(cb.logsOpen, ShouldResemble, map[types.StreamName]struct{}{}) 323 324 // Iterate over each generated stream and assert that it matches its 325 // expectation. Do it deterministically so failures aren't frustrating 326 // to reproduce. 327 Convey(`Has correct Step value`, func() { 328 rootStep := st.RootStep() 329 330 exp := loadStepProto(t, testCase.name, rootStep) 331 So(rootStep.Proto(), ShouldResembleProto, exp) 332 }) 333 334 // Iterate over each generated log and assert that it matches its 335 // expectations. 336 logs := make([]string, 0, len(cb.logs)) 337 for k := range cb.logs { 338 logs = append(logs, string(k)) 339 } 340 sort.Strings(logs) 341 for _, logName := range logs { 342 log := cb.logs[types.StreamName(logName)] 343 exp := loadLogText(t, testCase.name, logName) 344 So(log, ShouldResemble, exp) 345 } 346 }) 347 } 348 349 Convey(`Append to a closed State is a no-op.`, func() { 350 st := testCases[0].state(startTime) 351 st.Finish() 352 sclone := st 353 So(st.Append("asdf"), ShouldBeNil) 354 So(st, ShouldResemble, sclone) 355 }) 356 }) 357 }