// Copyright 2021 The Tint Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // fix-tests is a tool to update tests with new expected output. package main import ( "encoding/json" "flag" "fmt" "io/ioutil" "os" "os/exec" "path/filepath" "regexp" "strings" "dawn.googlesource.com/tint/tools/fix-tests/substr" ) func main() { if err := run(); err != nil { fmt.Println(err) os.Exit(1) } } func showUsage() { fmt.Println(` fix-tests is a tool to update tests with new expected output. fix-tests performs string matching and heuristics to fix up expected results of tests that use EXPECT_EQ(a, b) and EXPECT_THAT(a, HasSubstr(b)) WARNING: Always thoroughly check the generated output for mistakes. This may produce incorrect output Usage: fix-tests executable - the path to the test executable to run.`) os.Exit(1) } func run() error { flag.Parse() args := flag.Args() if len(args) < 1 { showUsage() } exe := args[0] // The path to the test executable wd := filepath.Dir(exe) // The directory holding the test exe // Create a temporary directory to hold the 'test-results.json' file tmpDir, err := ioutil.TempDir("", "fix-tests") if err != nil { return err } if err := os.MkdirAll(tmpDir, 0666); err != nil { return fmt.Errorf("Failed to create temporary directory: %w", err) } defer os.RemoveAll(tmpDir) // Full path to the 'test-results.json' in the temporary directory testResultsPath := filepath.Join(tmpDir, "test-results.json") // Run the tests switch err := exec.Command(exe, "--gtest_output=json:"+testResultsPath).Run().(type) { default: return err case nil: fmt.Println("All tests passed") case *exec.ExitError: } // Read the 'test-results.json' file testResultsFile, err := os.Open(testResultsPath) if err != nil { return err } var testResults Results if err := json.NewDecoder(testResultsFile).Decode(&testResults); err != nil { return err } // For each failing test... seen := map[string]bool{} numFixed, numFailed := 0, 0 for _, group := range testResults.Groups { for _, suite := range group.Testsuites { for _, failure := range suite.Failures { // .. attempt to fix the problem test := testName(group, suite) if seen[test] { continue } seen[test] = true if err := processFailure(test, wd, failure.Failure); err != nil { fmt.Println(fmt.Errorf("%v: %w", test, err)) numFailed++ } else { numFixed++ } } } } fmt.Println() if numFailed > 0 { fmt.Println(numFailed, "tests could not be fixed") } if numFixed > 0 { fmt.Println(numFixed, "tests fixed") } return nil } func testName(group TestsuiteGroup, suite Testsuite) string { groupParts := strings.Split(group.Name, "/") suiteParts := strings.Split(suite.Name, "/") return groupParts[len(groupParts)-1] + "." + suiteParts[0] } var ( // Regular expression to match a test declaration reTests = regexp.MustCompile(`TEST(?:_[FP])?\([ \n]*(\w+),[ \n]*(\w+)\)`) // Regular expression to match a `EXPECT_EQ(a, b)` failure for strings reExpectEq = regexp.MustCompile(`([./\\a-z_-]*):(\d+).*\nExpected equality of these values:\n(?:.|\n)*?(?:Which is: | )"((?:.|\n)*?[^\\])"\n(?:.|\n)*?(?:Which is: | )"((?:.|\n)*?[^\\])"`) // Regular expression to match a `EXPECT_THAT(a, HasSubstr(b))` failure for strings reExpectHasSubstr = regexp.MustCompile(`([./\\a-z_-]*):(\d+).*\nValue of: .*\nExpected: has substring "((?:.|\n)*?[^\\])"\n Actual: "((?:.|\n)*?[^\\])"`) ) func processFailure(test, wd, failure string) error { // Start by un-escaping newlines in the failure message failure = strings.ReplaceAll(failure, "\\n", "\n") // Matched regex strings will also need to be un-escaped, but do this after // the match, as unescaped quotes may upset the regex patterns unescape := func(s string) string { return strings.ReplaceAll(s, `\"`, `"`) } escape := func(s string) string { s = strings.ReplaceAll(s, "\n", `\n`) s = strings.ReplaceAll(s, "\"", `\"`) return s } // Look for a EXPECT_EQ failure pattern var file string var fix func(testSource string) (string, error) if parts := reExpectEq.FindStringSubmatch(failure); len(parts) == 5 { // EXPECT_EQ(a, b) a, b := unescape(parts[3]), unescape(parts[4]) file = parts[1] fix = func(testSource string) (string, error) { // We don't know if a or b is the expected, so just try flipping the string // to the other form. switch { case strings.Contains(testSource, a): testSource = strings.Replace(testSource, a, b, -1) case strings.Contains(testSource, b): testSource = strings.Replace(testSource, b, a, -1) default: // Try escaping for R"(...)" strings a, b = escape(a), escape(b) switch { case strings.Contains(testSource, a): testSource = strings.Replace(testSource, a, b, -1) case strings.Contains(testSource, b): testSource = strings.Replace(testSource, b, a, -1) default: return "", fmt.Errorf("Could not fix 'EXPECT_EQ' pattern in '%v'", file) } } return testSource, nil } } else if parts := reExpectHasSubstr.FindStringSubmatch(failure); len(parts) == 5 { // EXPECT_THAT(a, HasSubstr(b)) a, b := unescape(parts[4]), unescape(parts[3]) file = parts[1] fix = func(testSource string) (string, error) { if fix := substr.Fix(a, b); fix != "" { if !strings.Contains(testSource, b) { // Try escaping for R"(...)" strings b, fix = escape(b), escape(fix) } if strings.Contains(testSource, b) { testSource = strings.Replace(testSource, b, fix, -1) return testSource, nil } return "", fmt.Errorf("Could apply fix for 'HasSubstr' pattern in '%v'", file) } return "", fmt.Errorf("Could find fix for 'HasSubstr' pattern in '%v'", file) } } else { return fmt.Errorf("Cannot fix this type of failure") } // Get the path to the source file containing the test failure sourcePath := filepath.Join(wd, file) // Parse the source file, split into tests sourceFile, err := parseSourceFile(sourcePath) if err != nil { return fmt.Errorf("Couldn't parse tests from file '%v': %w", file, err) } // Find the test testIdx, ok := sourceFile.tests[test] if !ok { return fmt.Errorf("Test not found in '%v'", file) } // Grab the source for the particular test testSource := sourceFile.parts[testIdx] if testSource, err = fix(testSource); err != nil { return err } // Replace the part of the source file sourceFile.parts[testIdx] = testSource // Write out the source file return writeSourceFile(sourcePath, sourceFile) } // parseSourceFile() reads the file at path, splitting the content into chunks // for each TEST. func parseSourceFile(path string) (sourceFile, error) { fileBytes, err := ioutil.ReadFile(path) if err != nil { return sourceFile{}, err } fileContent := string(fileBytes) out := sourceFile{ tests: map[string]int{}, } pos := 0 for _, span := range reTests.FindAllStringIndex(fileContent, -1) { out.parts = append(out.parts, fileContent[pos:span[0]]) pos = span[0] match := reTests.FindStringSubmatch(fileContent[span[0]:span[1]]) group := match[1] suite := match[2] out.tests[group+"."+suite] = len(out.parts) } out.parts = append(out.parts, fileContent[pos:]) return out, nil } // writeSourceFile() joins the chunks of the file, and writes the content out to // path. func writeSourceFile(path string, file sourceFile) error { body := strings.Join(file.parts, "") return ioutil.WriteFile(path, []byte(body), 0666) } type sourceFile struct { parts []string tests map[string]int // "X.Y" -> part index } // Results is the root JSON structure of the JSON --gtest_output file . type Results struct { Tests int `json:"tests"` Failures int `json:"failures"` Disabled int `json:"disabled"` Errors int `json:"errors"` Timestamp string `json:"timestamp"` Time string `json:"time"` Name string `json:"name"` Groups []TestsuiteGroup `json:"testsuites"` } // TestsuiteGroup is a group of test suites in the JSON --gtest_output file . type TestsuiteGroup struct { Name string `json:"name"` Tests int `json:"tests"` Failures int `json:"failures"` Disabled int `json:"disabled"` Errors int `json:"errors"` Timestamp string `json:"timestamp"` Time string `json:"time"` Testsuites []Testsuite `json:"testsuite"` } // Testsuite is a suite of tests in the JSON --gtest_output file. type Testsuite struct { Name string `json:"name"` ValueParam string `json:"value_param,omitempty"` Status Status `json:"status"` Result Result `json:"result"` Timestamp string `json:"timestamp"` Time string `json:"time"` Classname string `json:"classname"` Failures []Failure `json:"failures,omitempty"` } // Failure is a reported test failure in the JSON --gtest_output file. type Failure struct { Failure string `json:"failure"` Type string `json:"type"` } // Status is a status code in the JSON --gtest_output file. type Status string // Result is a result code in the JSON --gtest_output file. type Result string