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  }