k8s.io/test-infra/triage@v0.0.0-20240520184403-27c6b4c223d8/summarize/summarize_test.go (about)

     1  /*
     2  Copyright 2020 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package summarize
    18  
    19  import (
    20  	"encoding/json"
    21  	"fmt"
    22  	"os"
    23  	"testing"
    24  )
    25  
    26  // smear takes a slice of map deltas and returns a slice of maps.
    27  func smear(deltas []map[string]string) []map[string]string {
    28  	cur := make(map[string]string)
    29  	out := make([]map[string]string, 0, len(deltas))
    30  
    31  	for _, delta := range deltas {
    32  		for key, val := range delta {
    33  			// Create a key-value mapping, or replace the value with the new one if it exists
    34  			cur[key] = val
    35  		}
    36  
    37  		// Copy cur to avoid messing with the original map
    38  		curCopy := make(map[string]string, len(cur))
    39  		for key, val := range cur {
    40  			curCopy[key] = val
    41  		}
    42  
    43  		out = append(out, curCopy)
    44  	}
    45  
    46  	return out
    47  }
    48  
    49  // failOnDifferentLengths fails the provided test if wantedLen != gotLen. Returns true if the test
    50  // failed, false otherwise.
    51  func failOnDifferentLengths(t *testing.T, wantedLen int, gotLen int) bool {
    52  	if wantedLen != gotLen {
    53  		t.Errorf("Wanted result and actual result have different lengths (%d vs. %d)", wantedLen, gotLen)
    54  		return true
    55  	}
    56  	return false
    57  }
    58  
    59  // failOnMismatchedSlices fails the provided test if the provided slices have different lengths or
    60  // if their elements do not match (in order). It returns true if it failed and false otherwise.
    61  //
    62  // The slices must both be of the same type, and must be string slices or int slices. The function
    63  // panics if either of these is not true.
    64  func failOnMismatchedSlices(t *testing.T, want interface{}, got interface{}) bool {
    65  	switch want := want.(type) {
    66  	case []string:
    67  		switch got := got.(type) {
    68  		case []string:
    69  			if failOnDifferentLengths(t, len(want), len(got)) {
    70  				return true
    71  			}
    72  			for i := range want {
    73  				if want[i] != got[i] {
    74  					t.Errorf("Wanted value and actual value did not match.\nWanted: %#v\nActual: %#v", want, got)
    75  					return true
    76  				}
    77  			}
    78  		default:
    79  			t.Logf("Type of want does not equal type of got")
    80  			t.FailNow()
    81  		}
    82  	case []int:
    83  		switch got := got.(type) {
    84  		case []int:
    85  			if failOnDifferentLengths(t, len(want), len(got)) {
    86  				return true
    87  			}
    88  			for i := range want {
    89  				if want[i] != got[i] {
    90  					t.Errorf("Wanted value and actual value did not match.\nWanted: %#v\nActual: %#v", want, got)
    91  					return true
    92  				}
    93  			}
    94  		default:
    95  			t.Logf("Type of want does not equal type of got")
    96  			t.FailNow()
    97  		}
    98  	default:
    99  		t.Logf("want and got must be of type []string or []int")
   100  		t.FailNow()
   101  	}
   102  
   103  	return false
   104  }
   105  
   106  // failOnMismatchedTestSlices fails the provided test t if the provided test slices have different
   107  // lengths or if their elements do not match (in order). It creates a series of subtests for teach
   108  // pair of tests in the slices.
   109  func failOnMismatchedTestSlices(t *testing.T, want []test, got []test) {
   110  	if failOnDifferentLengths(t, len(want), len(want)) {
   111  		return
   112  	}
   113  	for j := range want {
   114  		wantTest := want[j]
   115  		gotTest := got[j]
   116  
   117  		t.Run(fmt.Sprintf("tests[%d]", j), func(t *testing.T) {
   118  			if wantTest.Name != gotTest.Name {
   119  				t.Errorf("name = %s, wanted %s", gotTest.Name, wantTest.Name)
   120  				return
   121  			}
   122  			if failOnDifferentLengths(t, len(wantTest.Jobs), len(gotTest.Jobs)) {
   123  				return
   124  			}
   125  			for k := range wantTest.Jobs {
   126  				wantJobs := wantTest.Jobs[k]
   127  				gotJobs := gotTest.Jobs[k]
   128  				if wantJobs.Name != gotJobs.Name {
   129  					t.Errorf("name = %s, wanted %s", gotJobs.Name, wantJobs.Name)
   130  					return
   131  				}
   132  				failOnMismatchedSlices(t, wantJobs.BuildNumbers, gotJobs.BuildNumbers)
   133  			}
   134  		})
   135  	}
   136  }
   137  
   138  func TestSummarize(t *testing.T) {
   139  	// Setup
   140  	tmpdir := t.TempDir()
   141  
   142  	// Save the old working directory
   143  	olddir, err := os.Getwd()
   144  	if err != nil {
   145  		t.Errorf("Could not get the application working directory: %s", err)
   146  		return
   147  	}
   148  	err = os.Chdir(tmpdir) // Set the working directory to the temp directory
   149  	if err != nil {
   150  		t.Errorf("Could not set the application working directory to the temp directory: %s", err)
   151  		return
   152  	}
   153  	defer func() {
   154  		err = os.Chdir(olddir) // Set the working directory back to the normal directory
   155  		if err != nil {
   156  			t.Errorf("Could not set the application working directory back to the normal directory: %s", err)
   157  			return
   158  		}
   159  	}()
   160  
   161  	// Create some test input files
   162  
   163  	// builds
   164  	buildsPath := "builds.json"
   165  	builds := smear([]map[string]string{
   166  		{"started": "1234", "number": "1", "tests_failed": "1", "tests_run": "2", "elapsed": "4",
   167  			"path": "gs://logs/some-job/1", "job": "some-job", "result": "SUCCESS"},
   168  		{"number": "2", "path": "gs://logs/some-job/2"},
   169  		{"number": "3", "path": "gs://logs/some-job/3"},
   170  		{"number": "4", "path": "gs://logs/some-job/4"},
   171  		{"number": "5", "path": "gs://logs/other-job/5", "job": "other-job", "elapsed": "8"},
   172  		{"number": "7", "path": "gs://logs/other-job/7", "result": "FAILURE"},
   173  	})
   174  	err = writeJSON(buildsPath, builds)
   175  	if err != nil {
   176  		t.Errorf("Could not write builds.json: %s", err)
   177  		return
   178  	}
   179  
   180  	// tests
   181  	testsPath := "tests.json"
   182  	testsTemp := smear([]map[string]string{
   183  		{"name": "example test", "build": "gs://logs/some-job/1",
   184  			"failure_text": "some awful stack trace exit 1"},
   185  		{"build": "gs://logs/some-job/2"},
   186  		{"build": "gs://logs/some-job/3"},
   187  		{"build": "gs://logs/some-job/4"},
   188  		{"name": "another test", "failure_text": "some other error message"},
   189  		{"name": "unrelated test", "build": "gs://logs/other-job/5"},
   190  		{}, // Intentional dupe
   191  		{"build": "gs://logs/other-job/7"},
   192  	})
   193  	tests := make([]byte, 0)
   194  	for _, obj := range testsTemp {
   195  		result, err := json.Marshal(obj)
   196  		if err != nil {
   197  			t.Errorf("Could not encode JSON.\nError: %s\nObject: %#v", err, obj)
   198  			return
   199  		}
   200  
   201  		tests = append(tests, result...)
   202  		tests = append(tests, []byte("\n")...)
   203  	}
   204  	err = os.WriteFile(testsPath, tests, 0644)
   205  	if err != nil {
   206  		t.Errorf("Could not write JSON to file: %s", err)
   207  		return
   208  	}
   209  
   210  	// owners
   211  	ownersPath := "owners.json"
   212  	err = writeJSON(ownersPath, map[string][]string{"node": {"example"}})
   213  	if err != nil {
   214  		t.Errorf("Could not write JSON to file: %s", err)
   215  		return
   216  	}
   217  
   218  	// Call summarize()
   219  	summarize(summarizeFlags{
   220  		builds:               buildsPath,
   221  		tests:                []string{testsPath},
   222  		previous:             "",
   223  		owners:               ownersPath,
   224  		output:               "failure_data.json",
   225  		outputSlices:         "failure_data_PREFIX.json",
   226  		numWorkers:           4, // Arbitrary number to keep tests more or less consistent across platforms
   227  		memoize:              false,
   228  		maxClusterTextLength: 10000,
   229  	})
   230  
   231  	// Test the output
   232  	var output jsonOutput
   233  	err = getJSON("failure_data.json", &output)
   234  	if err != nil {
   235  		t.Errorf("Could not retrieve summarize() results: %s", err)
   236  	}
   237  
   238  	// Grab two random hashes for use across some of the following tests
   239  	randomHash1 := output.Clustered[0].ID
   240  	randomHash2 := output.Clustered[1].ID
   241  
   242  	t.Run("Main output", func(t *testing.T) {
   243  		// Test each field as a subtest
   244  
   245  		t.Run("builds", func(t *testing.T) {
   246  			want := columns{
   247  				Cols: columnarBuilds{
   248  					Elapsed:  []int{8, 8, 4, 4, 4, 4},
   249  					Executor: []string{"", "", "", "", "", ""},
   250  					PR:       []string{"", "", "", "", "", ""},
   251  					Result: []string{
   252  						"SUCCESS",
   253  						"FAILURE",
   254  						"SUCCESS",
   255  						"SUCCESS",
   256  						"SUCCESS",
   257  						"SUCCESS",
   258  					},
   259  					Started:     []int{1234, 1234, 1234, 1234, 1234, 1234},
   260  					TestsFailed: []int{1, 1, 1, 1, 1, 1},
   261  					TestsRun:    []int{2, 2, 2, 2, 2, 2},
   262  				},
   263  				JobPaths: map[string]string{
   264  					"other-job": "gs://logs/other-job",
   265  					"some-job":  "gs://logs/some-job",
   266  				},
   267  				Jobs: map[string]jobCollection{
   268  					// JSON keys are always strings, so although this is created as a map[int]x,
   269  					// we'll check it as a map[string]x
   270  					"other-job": map[string]int{"5": 0, "7": 1},
   271  					"some-job":  []int{1, 4, 2},
   272  				},
   273  			}
   274  
   275  			got := output.Builds
   276  
   277  			// Go through each of got's sections and determine if they match want
   278  			t.Run("cols", func(t *testing.T) {
   279  				wantCols := want.Cols
   280  				gotCols := got.Cols
   281  
   282  				intTestCases := []struct {
   283  					name string
   284  					got  []int
   285  					want []int
   286  				}{
   287  					{"elapsed", gotCols.Elapsed, wantCols.Elapsed},
   288  					{"started", gotCols.Started, wantCols.Started},
   289  					{"testsFailed", gotCols.TestsFailed, wantCols.TestsFailed},
   290  					{"testsRun", gotCols.TestsRun, wantCols.TestsRun},
   291  				}
   292  
   293  				for _, tc := range intTestCases {
   294  					t.Run(tc.name, func(t *testing.T) {
   295  						failOnMismatchedSlices(t, tc.want, tc.got)
   296  					})
   297  				}
   298  
   299  				stringTestCases := []struct {
   300  					name string
   301  					got  []string
   302  					want []string
   303  				}{
   304  					{"executor", gotCols.Executor, wantCols.Executor},
   305  					{"pr", gotCols.PR, wantCols.PR},
   306  					{"result", gotCols.Result, wantCols.Result},
   307  				}
   308  
   309  				for _, tc := range stringTestCases {
   310  					t.Run(tc.name, func(t *testing.T) {
   311  						failOnMismatchedSlices(t, tc.want, tc.got)
   312  					})
   313  				}
   314  			})
   315  
   316  			t.Run("jobPaths", func(t *testing.T) {
   317  				wantJobPaths := want.JobPaths
   318  				gotJobPaths := got.JobPaths
   319  
   320  				if failOnDifferentLengths(t, len(wantJobPaths), len(gotJobPaths)) {
   321  					return
   322  				}
   323  
   324  				failed := false
   325  				for key, wantedResult := range wantJobPaths {
   326  					// Check if each want key exists in got
   327  					if gotResult, ok := gotJobPaths[key]; ok {
   328  						// If so, do their values match?
   329  						if wantedResult != gotResult {
   330  							failed = true
   331  							break
   332  						}
   333  					} else {
   334  						failed = true
   335  						break
   336  					}
   337  				}
   338  
   339  				if failed {
   340  					t.Errorf("Wanted result (%#v) and actual result (%#v) do not match", wantJobPaths, gotJobPaths)
   341  				}
   342  			})
   343  
   344  			t.Run("jobs", func(t *testing.T) {
   345  				wantJobs := want.Jobs
   346  				gotJobs := got.Jobs
   347  
   348  				if failOnDifferentLengths(t, len(wantJobs), len(gotJobs)) {
   349  					return
   350  				}
   351  
   352  				for jobName := range wantJobs {
   353  					t.Run(jobName, func(t *testing.T) {
   354  						// The values before they are type-checked
   355  						wantJobCollection := wantJobs[jobName]
   356  						gotJobCollection := gotJobs[jobName]
   357  
   358  						switch wantJobCollection := wantJobCollection.(type) {
   359  						case map[string]int:
   360  							wantMap := wantJobCollection
   361  							// json.Unmarshal converts objects to map[string]interface{}, we'll have
   362  							// to check the type of each value separately
   363  							gotMap := gotJobCollection.(map[string]interface{})
   364  
   365  							if failOnDifferentLengths(t, len(wantMap), len(gotMap)) {
   366  								return
   367  							}
   368  							for key := range wantMap {
   369  								wantVal := wantMap[key]
   370  								if gotVal, ok := gotMap[key]; ok {
   371  									// json.Unmarshal represents all numbers as float64, convert to int
   372  									if wantVal != int(gotVal.(float64)) {
   373  										t.Errorf("Wanted value of %d for key '%s', got %d", wantVal, key, int(gotVal.(float64)))
   374  									}
   375  								} else {
   376  									t.Errorf("No value in gotMap for key '%s'", key)
   377  								}
   378  							}
   379  						case []int:
   380  							wantSlice := wantJobCollection
   381  							// json.Unmarshal converts slices to []interface{}, we'll have
   382  							// to check the type of each value separately
   383  							gotSlice := gotJobCollection.([]interface{})
   384  							if failOnDifferentLengths(t, len(wantSlice), len(gotSlice)) {
   385  								return
   386  							}
   387  							for i := range wantSlice {
   388  								// json.Unmarshal represents all numbers as float64, convert to int
   389  								if wantSlice[i] != int(gotSlice[i].(float64)) {
   390  									t.Errorf("Want slice (%#v) does not match got slice (%#v)", wantSlice, gotSlice)
   391  									return
   392  								}
   393  							}
   394  						}
   395  					})
   396  				}
   397  			})
   398  		})
   399  
   400  		t.Run("clustered", func(t *testing.T) {
   401  			want := []jsonCluster{
   402  				{
   403  					ID:  randomHash1,
   404  					Key: "some awful stack trace exit 1",
   405  					Tests: []test{
   406  						{
   407  							Jobs: []job{
   408  								{
   409  									BuildNumbers: []string{"4", "3", "2", "1"},
   410  									Name:         "some-job",
   411  								},
   412  							},
   413  							Name: "example test",
   414  						},
   415  					},
   416  					Spans: []int{29},
   417  					Owner: "node",
   418  					Text:  "some awful stack trace exit 1",
   419  				},
   420  				{
   421  					ID:  randomHash2,
   422  					Key: "some other error message",
   423  					Tests: []test{
   424  						{
   425  							Jobs: []job{
   426  								{
   427  									BuildNumbers: []string{"7", "5"},
   428  									Name:         "other-job",
   429  								},
   430  							},
   431  							Name: "unrelated test",
   432  						},
   433  						{
   434  							Jobs: []job{
   435  								{
   436  									BuildNumbers: []string{"4"},
   437  									Name:         "some-job",
   438  								},
   439  							},
   440  							Name: "another test",
   441  						},
   442  					},
   443  					Spans: []int{24},
   444  					Owner: "testing",
   445  					Text:  "some other error message",
   446  				},
   447  			}
   448  
   449  			got := output.Clustered
   450  
   451  			// Check lengths
   452  			if failOnDifferentLengths(t, len(want), len(got)) {
   453  				return
   454  			}
   455  
   456  			// Go through each of got's sections and determine if they match want
   457  			for i := range want {
   458  				currentWant := want[i]
   459  				currentGot := got[i]
   460  				t.Run(fmt.Sprintf("want[%d]", i), func(t *testing.T) {
   461  					// Simple string equality checking
   462  					stringTestCases := []struct {
   463  						name string
   464  						got  string
   465  						want string
   466  					}{
   467  						{"id", currentWant.ID, currentWant.ID},
   468  						{"key", currentWant.Key, currentWant.Key},
   469  						{"owner", currentWant.Owner, currentWant.Owner},
   470  						{"text", currentWant.Text, currentWant.Text},
   471  					}
   472  					for _, tc := range stringTestCases {
   473  						t.Run(tc.name, func(t *testing.T) {
   474  							if tc.want != tc.got {
   475  								t.Errorf("Wanted result (%s) and actual result (%s) do not match", tc.want, tc.got)
   476  							}
   477  						})
   478  					}
   479  
   480  					// Simple int slice
   481  					t.Run("spans", func(t *testing.T) {
   482  						failOnMismatchedSlices(t, currentWant.Spans, currentWant.Spans)
   483  					})
   484  
   485  					// tests
   486  					t.Run("tests", func(t *testing.T) {
   487  						failOnMismatchedTestSlices(t, currentWant.Tests, currentGot.Tests)
   488  					})
   489  				})
   490  			}
   491  		})
   492  	})
   493  
   494  	t.Run("Slices", func(t *testing.T) {
   495  		var renderedSlice renderedSliceOutput
   496  
   497  		filepath := fmt.Sprintf("failure_data_%s.json", randomHash1[:2])
   498  
   499  		err := getJSON(filepath, &renderedSlice)
   500  		if err != nil {
   501  			t.Error(err)
   502  			return
   503  		}
   504  
   505  		t.Run("clustered", func(t *testing.T) {
   506  			want := []jsonCluster{output.Clustered[0]}
   507  			got := renderedSlice.Clustered
   508  
   509  			if failOnDifferentLengths(t, len(want), len(got)) {
   510  				return
   511  			}
   512  			for i := range want {
   513  				t.Run(fmt.Sprintf("got[%d]", i), func(t *testing.T) {
   514  					stringTestCases := []struct {
   515  						name string
   516  						want string
   517  						got  string
   518  					}{
   519  						{"key", want[i].Key, got[i].Key},
   520  						{"id", want[i].ID, got[i].ID},
   521  						{"text", want[i].Text, got[i].Text},
   522  						{"owner", want[i].Owner, got[i].Owner},
   523  					}
   524  					for _, tc := range stringTestCases {
   525  						t.Run(tc.name, func(t *testing.T) {
   526  							if tc.got != tc.want {
   527  								t.Errorf("Wanted value (%#v) did not match actual value (%#v)", want, got)
   528  							}
   529  						})
   530  					}
   531  
   532  					t.Run("spans", func(t *testing.T) {
   533  						failOnMismatchedSlices(t, want[i].Spans, got[i].Spans)
   534  					})
   535  
   536  					t.Run("tests", func(t *testing.T) {
   537  						failOnMismatchedTestSlices(t, want[i].Tests, got[i].Tests)
   538  					})
   539  				})
   540  			}
   541  		})
   542  
   543  		t.Run("builds.cols.started", func(t *testing.T) {
   544  			want := []int{1234, 1234, 1234, 1234}
   545  			got := renderedSlice.Builds.Cols.Started
   546  
   547  			failOnMismatchedSlices(t, want, got)
   548  		})
   549  	})
   550  
   551  	// Call summarize() with no owners file
   552  	t.Run("No owners file", func(t *testing.T) {
   553  		summarize(summarizeFlags{
   554  			builds:       buildsPath,
   555  			tests:        []string{testsPath},
   556  			previous:     "",
   557  			owners:       "",
   558  			output:       "failure_data.json",
   559  			outputSlices: "failure_data_PREFIX.json",
   560  		})
   561  	})
   562  }