blob: 6647585b841f4e993447ff564cc46e2f4d318428 [file] [log] [blame]
// Copyright 2021 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
// 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"
"strconv"
"strings"
"dawn.googlesource.com/dawn/tools/src/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>
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
testArgs := []string{"--gtest_output=json:" + testResultsPath}
if len(args) > 1 {
testArgs = append(testArgs, args[1:]...)
}
switch err := exec.Command(exe, testArgs...).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(`([./\\\w_\-:]*):(\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(`([./\\\w_\-:]*):(\d+).*\nValue of: .*\nExpected: has substring "((?:.|\n)*?[^\\])"\n Actual: "((?:.|\n)*?[^\\])"`)
)
var reHexCode = regexp.MustCompile(`\\x([0-9A-Z]{2})`)
type MatchRange struct {
start int
end int // exclusive
}
func Size(mr MatchRange) int {
return mr.end - mr.start
}
// Look for 'search_str' in the 'doc_string' and return the longest matching substring of 'search_str' as start/end indices
func longestSubstringMatch(search_str string, doc_string string) MatchRange {
// Brute force algorithm is n*m for string sizes n and m
best_idx_start := 0
best_idx_end := 0 // exclusive
// Scan the document with the search string from the highest offset to the lowest. This will be out of bound for search string but that is because we are looking of a substring.
for doc_offset := -len(search_str); doc_offset < len(doc_string)+len(search_str); doc_offset++ {
curr_idx_start := 0
curr_idx_end := 0 // exclusive
for search_idx := 0; search_idx < len(search_str); search_idx++ {
doc_offset := search_idx + doc_offset
is_match := false
// basic range checking for the doc
if doc_offset >= 0 && doc_offset < len(doc_string) {
if search_str[search_idx] == doc_string[doc_offset] {
is_match = true
}
}
if is_match {
if curr_idx_end == 0 {
// first time matching
curr_idx_start = search_idx
curr_idx_end = curr_idx_start + 1 // exclusive
} else {
// continue current matching.
curr_idx_end++
}
}
// check if our match is the best
best_size := best_idx_end - best_idx_start
curr_size := curr_idx_end - curr_idx_start
if best_size < curr_size {
best_idx_start = curr_idx_start
best_idx_end = curr_idx_end
}
if !is_match {
// reset
curr_idx_start = 0
curr_idx_end = 0
}
}
}
return MatchRange{best_idx_start, best_idx_end}
}
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 {
s = strings.ReplaceAll(s, `\"`, `"`)
s = strings.ReplaceAll(s, `\\`, `\`)
s = reHexCode.ReplaceAllStringFunc(s, func(match string) string {
i, err := strconv.ParseInt(match[2:], 16, 32)
if err != nil {
panic(err)
}
return string([]byte{byte(i)})
})
return 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) {
// This code is written so it can insert changes as fixes for expects that only match a substring.
// An example of where this is required is the "glsl/writer/builtin_test.cc" due to the programmatic header (GlslHeader())
// We don't know if a or b is the expected. We also dont know if it should be escaped for R"(...)" strings
a_esc, b_esc := escape(a), escape(b)
// Find the longest match. We have (unfortunately) 4 options.
mr_a := longestSubstringMatch(a, testSource)
mr_b := longestSubstringMatch(b, testSource)
mr_a_esc := longestSubstringMatch(a_esc, testSource)
mr_b_esc := longestSubstringMatch(b_esc, testSource)
is_largest := func(mr MatchRange) bool {
return Size(mr) >= Size(mr_a) && Size(mr) >= Size(mr_b) &&
Size(mr) >= Size(mr_a_esc) && Size(mr) >= Size(mr_b_esc)
}
// assumed mr_b_esc is best match
expected_str := b_esc
replace_str := a_esc
mr_largest := mr_b_esc
if is_largest(mr_a) {
expected_str = a
replace_str = b
mr_largest = mr_a
} else if is_largest(mr_b) {
expected_str = b
replace_str = a
mr_largest = mr_b
} else if is_largest(mr_a_esc) {
expected_str = a_esc
replace_str = b_esc
mr_largest = mr_a_esc
}
// trim away the number of unmatched characters from the end of expected to the end of the replacement.
replace_str_end := len(replace_str) - (len(expected_str) - mr_largest.end)
if replace_str_end >= mr_largest.start && replace_str_end <= len(replace_str) {
replace_str = replace_str[mr_largest.start:replace_str_end]
expected_str = expected_str[mr_largest.start:mr_largest.end]
} else {
// It is not safe to attempt a replace if the replacement string would have negative (nonsense) size.
expected_str = ""
}
// Do not try to replace on empty strings.
if len(expected_str) <= 0 {
return "", fmt.Errorf("could not fix 'EXPECT_EQ' pattern in '%v'\n\nA: '%v'\n\nB: '%v'", file, a, b)
}
testSource = strings.ReplaceAll(testSource, expected_str, replace_str)
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 absolute source path
sourcePath := file
if !filepath.IsAbs(sourcePath) {
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