github.com/mponton/terratest@v0.44.0/modules/logger/parser/parser.go (about)

     1  // Package logger/parser contains methods to parse and restructure log output from go testing and terratest
     2  package parser
     3  
     4  import (
     5  	"bufio"
     6  	"io"
     7  	"os"
     8  	"regexp"
     9  	"strings"
    10  	"sync"
    11  
    12  	junitparser "github.com/jstemmer/go-junit-report/parser"
    13  	"github.com/sirupsen/logrus"
    14  )
    15  
    16  // SpawnParsers will spawn the log parser and junit report parsers off of a single reader.
    17  func SpawnParsers(logger *logrus.Logger, reader io.Reader, outputDir string) {
    18  	forkedReader, forkedWriter := io.Pipe()
    19  	teedReader := io.TeeReader(reader, forkedWriter)
    20  	var waitForParsers sync.WaitGroup
    21  	waitForParsers.Add(2)
    22  	go func() {
    23  		// close pipe writer, because this section drains the tee reader indicating reader is done draining
    24  		defer forkedWriter.Close()
    25  		defer waitForParsers.Done()
    26  		parseAndStoreTestOutput(logger, teedReader, outputDir)
    27  	}()
    28  	go func() {
    29  		defer waitForParsers.Done()
    30  		report, err := junitparser.Parse(forkedReader, "")
    31  		if err == nil {
    32  			storeJunitReport(logger, outputDir, report)
    33  		} else {
    34  			logger.Errorf("Error parsing test output into junit report: %s", err)
    35  		}
    36  	}()
    37  	waitForParsers.Wait()
    38  }
    39  
    40  // RegEx for parsing test status lines. Pulled from jstemmer/go-junit-report
    41  var (
    42  	regexResult  = regexp.MustCompile(`--- (PASS|FAIL|SKIP): (.+) \((\d+\.\d+)(?: ?seconds|s)\)`)
    43  	regexStatus  = regexp.MustCompile(`=== (RUN|PAUSE|CONT)\s+(.+)`)
    44  	regexSummary = regexp.MustCompile(`(^FAIL$)|(^(ok|FAIL)\s+([^ ]+)\s+(?:(\d+\.\d+)s|\(cached\)|(\[\w+ failed]))(?:\s+coverage:\s+(\d+\.\d+)%\sof\sstatements(?:\sin\s.+)?)?$)`)
    45  	regexPanic   = regexp.MustCompile(`^panic:`)
    46  )
    47  
    48  // getIndent takes a line and returns the indent string
    49  // Example:
    50  //
    51  //	in:  "    --- FAIL: TestSnafu"
    52  //	out: "    "
    53  func getIndent(data string) string {
    54  	re := regexp.MustCompile(`^\s+`)
    55  	indent := re.FindString(data)
    56  	return indent
    57  }
    58  
    59  // getTestNameFromResultLine takes a go testing result line and extracts out the test name
    60  // Example:
    61  //
    62  //	in:  --- FAIL: TestSnafu
    63  //	out: TestSnafu
    64  func getTestNameFromResultLine(text string) string {
    65  	m := regexResult.FindStringSubmatch(text)
    66  	return m[2]
    67  }
    68  
    69  // isResultLine checks if a line of text matches a test result (begins with "--- FAIL" or "--- PASS")
    70  func isResultLine(text string) bool {
    71  	return regexResult.MatchString(text)
    72  }
    73  
    74  // getTestNameFromStatusLine takes a go testing status line and extracts out the test name
    75  // Example:
    76  //
    77  //	in:  === RUN  TestSnafu
    78  //	out: TestSnafu
    79  func getTestNameFromStatusLine(text string) string {
    80  	m := regexStatus.FindStringSubmatch(text)
    81  	return m[2]
    82  }
    83  
    84  // isStatusLine checks if a line of text matches a test status
    85  func isStatusLine(text string) bool {
    86  	return regexStatus.MatchString(text)
    87  }
    88  
    89  // isSummaryLine checks if a line of text matches the test summary
    90  func isSummaryLine(text string) bool {
    91  	return regexSummary.MatchString(text)
    92  }
    93  
    94  // isPanicLine checks if a line of text matches a panic
    95  func isPanicLine(text string) bool {
    96  	return regexPanic.MatchString(text)
    97  }
    98  
    99  // parseAndStoreTestOutput will take test log entries from terratest and aggregate the output by test. Takes advantage
   100  // of the fact that terratest logs are prefixed by the test name. This will store the broken out logs into files under
   101  // the outputDir, named by test name.
   102  // Additionally will take test result lines and collect them under a summary log file named `summary.log`.
   103  // See the `fixtures` directory for some examples.
   104  func parseAndStoreTestOutput(
   105  	logger *logrus.Logger,
   106  	read io.Reader,
   107  	outputDir string,
   108  ) {
   109  	logWriter := LogWriter{
   110  		lookup:    make(map[string]*os.File),
   111  		outputDir: outputDir,
   112  	}
   113  	defer logWriter.closeFiles(logger)
   114  
   115  	// Track some state that persists across lines
   116  	testResultMarkers := TestResultMarkerStack{}
   117  	previousTestName := ""
   118  
   119  	var err error
   120  	reader := bufio.NewReader(read)
   121  	for {
   122  		var data string
   123  		data, err = reader.ReadString('\n')
   124  		if len(data) == 0 && err == io.EOF {
   125  			break
   126  		}
   127  
   128  		data = strings.TrimSuffix(data, "\n")
   129  
   130  		// separate block so that we do not overwrite the err variable that we need afterwards to check if we're done
   131  		{
   132  			indentLevel := len(getIndent(data))
   133  			isIndented := indentLevel > 0
   134  
   135  			// Garbage collection of test result markers. Primary purpose is to detect when we dedent out, which can only be
   136  			// detected when we reach a dedented line.
   137  			testResultMarkers = testResultMarkers.removeDedentedTestResultMarkers(indentLevel)
   138  
   139  			// Handle each possible category of test lines
   140  			switch {
   141  			case isSummaryLine(data):
   142  				logWriter.writeLog(logger, "summary", data)
   143  
   144  			case isStatusLine(data):
   145  				testName := getTestNameFromStatusLine(data)
   146  				previousTestName = testName
   147  				logWriter.writeLog(logger, testName, data)
   148  
   149  			case strings.HasPrefix(data, "Test"):
   150  				// Heuristic: `go test` will only execute test functions named `Test.*`, so we assume any line prefixed
   151  				// with `Test` is a test output for a named test. Also assume that test output will be space delimeted and
   152  				// test names can't contain spaces (because they are function names).
   153  				// This must be modified when `logger.DoLog` changes.
   154  				vals := strings.Split(data, " ")
   155  				testName := vals[0]
   156  				previousTestName = testName
   157  				logWriter.writeLog(logger, testName, data)
   158  
   159  			case isIndented && isResultLine(data):
   160  				// In a nested test result block, so collect the line into all the test results we have seen so far.
   161  				for _, marker := range testResultMarkers {
   162  					logWriter.writeLog(logger, marker.TestName, data)
   163  				}
   164  
   165  			case isPanicLine(data):
   166  				// When panic, we want all subsequent nonstandard test lines to roll up to the summary
   167  				previousTestName = "summary"
   168  				logWriter.writeLog(logger, "summary", data)
   169  
   170  			case isResultLine(data):
   171  				// We ignore result lines, because that is handled specially below.
   172  
   173  			case previousTestName != "":
   174  				// Base case: roll up to the previous test line, if it exists.
   175  				// Handles case where terratest log has entries with newlines in them.
   176  				logWriter.writeLog(logger, previousTestName, data)
   177  
   178  			default:
   179  				logger.Warnf("Found test line that does not match known cases: %s", data)
   180  			}
   181  
   182  			// This has to happen separately from main if block to handle the special case of nested tests (e.g table driven
   183  			// tests). For those result lines, we want it to roll up to the parent test, so we need to run the handler in
   184  			// the `isIndented` section. But for both root and indented result lines, we want to execute the following code,
   185  			// hence this special block.
   186  			if isResultLine(data) {
   187  				testName := getTestNameFromResultLine(data)
   188  				logWriter.writeLog(logger, testName, data)
   189  				logWriter.writeLog(logger, "summary", data)
   190  
   191  				marker := TestResultMarker{
   192  					TestName:    testName,
   193  					IndentLevel: indentLevel,
   194  				}
   195  				testResultMarkers = testResultMarkers.push(marker)
   196  			}
   197  		}
   198  
   199  		if err != nil {
   200  			break
   201  		}
   202  	}
   203  	if err != io.EOF {
   204  		logger.Fatalf("Error reading from Reader: %s", err)
   205  	}
   206  }