1// Copyright 2021 The Tint 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// fix-tests is a tool to update tests with new expected output. 16package main 17 18import ( 19 "encoding/json" 20 "flag" 21 "fmt" 22 "io/ioutil" 23 "os" 24 "os/exec" 25 "path/filepath" 26 "regexp" 27 "strings" 28 29 "dawn.googlesource.com/tint/tools/src/substr" 30) 31 32func main() { 33 if err := run(); err != nil { 34 fmt.Println(err) 35 os.Exit(1) 36 } 37} 38 39func showUsage() { 40 fmt.Println(` 41fix-tests is a tool to update tests with new expected output. 42 43fix-tests performs string matching and heuristics to fix up expected results of 44tests that use EXPECT_EQ(a, b) and EXPECT_THAT(a, HasSubstr(b)) 45 46WARNING: Always thoroughly check the generated output for mistakes. 47This may produce incorrect output 48 49Usage: 50 fix-tests <executable> 51 52 executable - the path to the test executable to run.`) 53 os.Exit(1) 54} 55 56func run() error { 57 flag.Parse() 58 args := flag.Args() 59 if len(args) < 1 { 60 showUsage() 61 } 62 63 exe := args[0] // The path to the test executable 64 wd := filepath.Dir(exe) // The directory holding the test exe 65 66 // Create a temporary directory to hold the 'test-results.json' file 67 tmpDir, err := ioutil.TempDir("", "fix-tests") 68 if err != nil { 69 return err 70 } 71 if err := os.MkdirAll(tmpDir, 0666); err != nil { 72 return fmt.Errorf("Failed to create temporary directory: %w", err) 73 } 74 defer os.RemoveAll(tmpDir) 75 76 // Full path to the 'test-results.json' in the temporary directory 77 testResultsPath := filepath.Join(tmpDir, "test-results.json") 78 79 // Run the tests 80 testArgs := []string{"--gtest_output=json:" + testResultsPath} 81 if len(args) > 1 { 82 testArgs = append(testArgs, args[1:]...) 83 } 84 switch err := exec.Command(exe, testArgs...).Run().(type) { 85 default: 86 return err 87 case nil: 88 fmt.Println("All tests passed") 89 case *exec.ExitError: 90 } 91 92 // Read the 'test-results.json' file 93 testResultsFile, err := os.Open(testResultsPath) 94 if err != nil { 95 return err 96 } 97 98 var testResults Results 99 if err := json.NewDecoder(testResultsFile).Decode(&testResults); err != nil { 100 return err 101 } 102 103 // For each failing test... 104 seen := map[string]bool{} 105 numFixed, numFailed := 0, 0 106 for _, group := range testResults.Groups { 107 for _, suite := range group.Testsuites { 108 for _, failure := range suite.Failures { 109 // .. attempt to fix the problem 110 test := testName(group, suite) 111 if seen[test] { 112 continue 113 } 114 seen[test] = true 115 116 if err := processFailure(test, wd, failure.Failure); err != nil { 117 fmt.Println(fmt.Errorf("%v: %w", test, err)) 118 numFailed++ 119 } else { 120 numFixed++ 121 } 122 } 123 } 124 } 125 126 fmt.Println() 127 128 if numFailed > 0 { 129 fmt.Println(numFailed, "tests could not be fixed") 130 } 131 if numFixed > 0 { 132 fmt.Println(numFixed, "tests fixed") 133 } 134 return nil 135} 136 137func testName(group TestsuiteGroup, suite Testsuite) string { 138 groupParts := strings.Split(group.Name, "/") 139 suiteParts := strings.Split(suite.Name, "/") 140 return groupParts[len(groupParts)-1] + "." + suiteParts[0] 141} 142 143var ( 144 // Regular expression to match a test declaration 145 reTests = regexp.MustCompile(`TEST(?:_[FP])?\([ \n]*(\w+),[ \n]*(\w+)\)`) 146 // Regular expression to match a `EXPECT_EQ(a, b)` failure for strings 147 reExpectEq = regexp.MustCompile(`([./\\\w_\-:]*):(\d+).*\nExpected equality of these values:\n(?:.|\n)*?(?:Which is: | )"((?:.|\n)*?[^\\])"\n(?:.|\n)*?(?:Which is: | )"((?:.|\n)*?[^\\])"`) 148 // Regular expression to match a `EXPECT_THAT(a, HasSubstr(b))` failure for strings 149 reExpectHasSubstr = regexp.MustCompile(`([./\\\w_\-:]*):(\d+).*\nValue of: .*\nExpected: has substring "((?:.|\n)*?[^\\])"\n Actual: "((?:.|\n)*?[^\\])"`) 150) 151 152func processFailure(test, wd, failure string) error { 153 // Start by un-escaping newlines in the failure message 154 failure = strings.ReplaceAll(failure, "\\n", "\n") 155 // Matched regex strings will also need to be un-escaped, but do this after 156 // the match, as unescaped quotes may upset the regex patterns 157 unescape := func(s string) string { 158 return strings.ReplaceAll(s, `\"`, `"`) 159 } 160 escape := func(s string) string { 161 s = strings.ReplaceAll(s, "\n", `\n`) 162 s = strings.ReplaceAll(s, "\"", `\"`) 163 return s 164 } 165 166 // Look for a EXPECT_EQ failure pattern 167 var file string 168 var fix func(testSource string) (string, error) 169 if parts := reExpectEq.FindStringSubmatch(failure); len(parts) == 5 { 170 // EXPECT_EQ(a, b) 171 a, b := unescape(parts[3]), unescape(parts[4]) 172 file = parts[1] 173 fix = func(testSource string) (string, error) { 174 // We don't know if a or b is the expected, so just try flipping the string 175 // to the other form. 176 177 if len(b) > len(a) { // Go with the longer match, in case both are found 178 a, b = b, a 179 } 180 switch { 181 case strings.Contains(testSource, a): 182 testSource = strings.ReplaceAll(testSource, a, b) 183 case strings.Contains(testSource, b): 184 testSource = strings.ReplaceAll(testSource, b, a) 185 default: 186 // Try escaping for R"(...)" strings 187 a, b = escape(a), escape(b) 188 switch { 189 case strings.Contains(testSource, a): 190 testSource = strings.ReplaceAll(testSource, a, b) 191 case strings.Contains(testSource, b): 192 testSource = strings.ReplaceAll(testSource, b, a) 193 default: 194 return "", fmt.Errorf("Could not fix 'EXPECT_EQ' pattern in '%v'", file) 195 } 196 } 197 return testSource, nil 198 } 199 } else if parts := reExpectHasSubstr.FindStringSubmatch(failure); len(parts) == 5 { 200 // EXPECT_THAT(a, HasSubstr(b)) 201 a, b := unescape(parts[4]), unescape(parts[3]) 202 file = parts[1] 203 fix = func(testSource string) (string, error) { 204 if fix := substr.Fix(a, b); fix != "" { 205 if !strings.Contains(testSource, b) { 206 // Try escaping for R"(...)" strings 207 b, fix = escape(b), escape(fix) 208 } 209 if strings.Contains(testSource, b) { 210 testSource = strings.Replace(testSource, b, fix, -1) 211 return testSource, nil 212 } 213 return "", fmt.Errorf("Could apply fix for 'HasSubstr' pattern in '%v'", file) 214 } 215 216 return "", fmt.Errorf("Could find fix for 'HasSubstr' pattern in '%v'", file) 217 } 218 } else { 219 return fmt.Errorf("Cannot fix this type of failure") 220 } 221 222 // Get the absolute source path 223 sourcePath := file 224 if !filepath.IsAbs(sourcePath) { 225 sourcePath = filepath.Join(wd, file) 226 } 227 228 // Parse the source file, split into tests 229 sourceFile, err := parseSourceFile(sourcePath) 230 if err != nil { 231 return fmt.Errorf("Couldn't parse tests from file '%v': %w", file, err) 232 } 233 234 // Find the test 235 testIdx, ok := sourceFile.tests[test] 236 if !ok { 237 return fmt.Errorf("Test not found in '%v'", file) 238 } 239 240 // Grab the source for the particular test 241 testSource := sourceFile.parts[testIdx] 242 243 if testSource, err = fix(testSource); err != nil { 244 return err 245 } 246 247 // Replace the part of the source file 248 sourceFile.parts[testIdx] = testSource 249 250 // Write out the source file 251 return writeSourceFile(sourcePath, sourceFile) 252} 253 254// parseSourceFile() reads the file at path, splitting the content into chunks 255// for each TEST. 256func parseSourceFile(path string) (sourceFile, error) { 257 fileBytes, err := ioutil.ReadFile(path) 258 if err != nil { 259 return sourceFile{}, err 260 } 261 fileContent := string(fileBytes) 262 263 out := sourceFile{ 264 tests: map[string]int{}, 265 } 266 267 pos := 0 268 for _, span := range reTests.FindAllStringIndex(fileContent, -1) { 269 out.parts = append(out.parts, fileContent[pos:span[0]]) 270 pos = span[0] 271 272 match := reTests.FindStringSubmatch(fileContent[span[0]:span[1]]) 273 group := match[1] 274 suite := match[2] 275 out.tests[group+"."+suite] = len(out.parts) 276 } 277 out.parts = append(out.parts, fileContent[pos:]) 278 279 return out, nil 280} 281 282// writeSourceFile() joins the chunks of the file, and writes the content out to 283// path. 284func writeSourceFile(path string, file sourceFile) error { 285 body := strings.Join(file.parts, "") 286 return ioutil.WriteFile(path, []byte(body), 0666) 287} 288 289type sourceFile struct { 290 parts []string 291 tests map[string]int // "X.Y" -> part index 292} 293 294// Results is the root JSON structure of the JSON --gtest_output file . 295type Results struct { 296 Tests int `json:"tests"` 297 Failures int `json:"failures"` 298 Disabled int `json:"disabled"` 299 Errors int `json:"errors"` 300 Timestamp string `json:"timestamp"` 301 Time string `json:"time"` 302 Name string `json:"name"` 303 Groups []TestsuiteGroup `json:"testsuites"` 304} 305 306// TestsuiteGroup is a group of test suites in the JSON --gtest_output file . 307type TestsuiteGroup struct { 308 Name string `json:"name"` 309 Tests int `json:"tests"` 310 Failures int `json:"failures"` 311 Disabled int `json:"disabled"` 312 Errors int `json:"errors"` 313 Timestamp string `json:"timestamp"` 314 Time string `json:"time"` 315 Testsuites []Testsuite `json:"testsuite"` 316} 317 318// Testsuite is a suite of tests in the JSON --gtest_output file. 319type Testsuite struct { 320 Name string `json:"name"` 321 ValueParam string `json:"value_param,omitempty"` 322 Status Status `json:"status"` 323 Result Result `json:"result"` 324 Timestamp string `json:"timestamp"` 325 Time string `json:"time"` 326 Classname string `json:"classname"` 327 Failures []Failure `json:"failures,omitempty"` 328} 329 330// Failure is a reported test failure in the JSON --gtest_output file. 331type Failure struct { 332 Failure string `json:"failure"` 333 Type string `json:"type"` 334} 335 336// Status is a status code in the JSON --gtest_output file. 337type Status string 338 339// Result is a result code in the JSON --gtest_output file. 340type Result string 341