File: process.go

package info (click to toggle)
golang-github-mfridman-tparse 0.18.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,780 kB
  • sloc: makefile: 53; sh: 41
file content (301 lines) | stat: -rw-r--r-- 8,781 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
package parse

import (
	"bufio"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"sort"
	"strconv"
	"strings"
)

// ErrNotParsable indicates the event line was not parsable.
var ErrNotParsable = errors.New("failed to parse")

// Process is the entry point to parse. It consumes a reader
// and parses go test output in JSON format until EOF.
//
// Note, Process will attempt to parse up to 50 lines before returning an error.
func Process(r io.Reader, optionsFunc ...OptionsFunc) (*GoTestSummary, error) {
	option := &options{}
	for _, f := range optionsFunc {
		f(option)
	}
	summary := &GoTestSummary{
		Packages: make(map[string]*Package),
	}

	noisy := []string{
		// 1. Filter out noisy output, such as === RUN, === PAUSE, etc.
		updatePrefixRun,
		updatePrefixPause,
		updatePrefixCont,
		updatePrefixPass,
		updatePrefixSkip,
		// 2. Filter out report output, such as --- PASS: and --- SKIP:
		resultPrefixPass,
		resultPrefixSkip,
	}
	isNoisy := func(e *Event) bool {
		output := strings.TrimSpace(e.Output)
		// If the event is a big pass or fail, we can safely discard it. These are typically the
		// lines preceding the package summary line. For example:
		//
		//  PASS
		//  ok      fmt 0.144s
		if e.Test == "" && (output == bigPass || output == bigFail) {
			return true
		}
		for _, prefix := range noisy {
			if strings.HasPrefix(output, prefix) {
				return true
			}
		}
		return false

	}

	sc := bufio.NewScanner(r)
	var started bool
	var badLines int
	for sc.Scan() {
		// Scan up-to 50 lines for a parsable event, if we get one, expect
		// no errors to follow until EOF.
		e, err := NewEvent(sc.Bytes())
		if err != nil {
			// We failed to parse a go test JSON event, but there are special cases for failed
			// builds, setup, etc. Let special case these and bubble them up in the summary
			// if the output belongs to a package.
			summary.AddRawEvent(sc.Text())

			badLines++
			if started || badLines > 50 {
				var syntaxError *json.SyntaxError
				if errors.As(err, &syntaxError) {
					err = fmt.Errorf("line %d JSON error: %s: %w", badLines, syntaxError.Error(), ErrNotParsable)
					if option.debug {
						// In debug mode we can surface a more verbose error message which
						// contains the current line number and exact JSON parsing error.
						fmt.Fprintf(os.Stderr, "debug: %s", err.Error())
					}
				}
				return nil, err
			}
			if option.follow && option.w != nil {
				fmt.Fprintf(option.w, "%s\n", sc.Bytes())
			}
			continue
		}
		started = true

		// TODO(mf): when running tparse locally it's very useful to see progress for long-running
		// test suites. Since we have access to the event we can send it on a chan
		// or just directly update a spinner-like component. This cannot be run with the
		// follow option. Lastly, need to consider what local vs CI behavior would be like.
		// Depending on how often the frames update, this could cause a lot of noise, so maybe
		// we need to expose an interval option, so in CI it would update infrequently.

		// Optionally, as test output is piped to us, we write the plain
		// text Output as if go test was run without the -json flag.
		if (option.follow || option.followVerbose) && option.w != nil {
			if !option.followVerbose && isNoisy(e) {
				continue
			}
			fmt.Fprint(option.w, e.Output)
		}
		// Progress is a special case of follow, where we only print the
		// progress of the test suite, but not the output.
		if option.progress && option.w != nil {
			printProgress(option.progressOutput, e, summary.Packages)
		}

		// TODO(mf): special case build output for now. Need to understand how to better handle this
		// But we don't want to swallow important build errors. There is a class of build output
		// that is bengin like: https://github.com/golang/go/issues/61229
		//
		//  Example:
		//  ld: warning: '.../go.o' has malformed LC_DYSYMTAB, expected 92 undefined symbols to start at index 15983, found 102 undefined symbol
		//
		// TL;DR - output ALL build output to stderr and exclude it from being added to test events
		if e.ImportPath != "" {
			if e.Output != "" {
				fmt.Fprint(os.Stderr, e.Output)
			}
			continue
		}

		summary.AddEvent(e)
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("received scanning error: %w", err)
	}
	// Entire input has been scanned and no go test JSON output was found.
	if !started {
		return nil, ErrNotParsable
	}

	return summary, nil
}

// printProgress prints a single summary line for each PASS or FAIL package.
// This is useful for long-running test suites.
func printProgress(w io.Writer, e *Event, summary map[string]*Package) {
	if !e.LastLine() {
		return
	}
	action := e.Action
	var suffix string
	if pkg, ok := summary[e.Package]; ok {
		if pkg.NoTests {
			suffix = " [no tests to run]"
			action = ActionSkip
		}
		if pkg.NoTestFiles {
			suffix = " [no test files]"
			action = ActionSkip
		}
	}
	// Normal go test output will print the package summary line like so:
	//
	// FAIL
	// FAIL    github.com/pressly/goose/v4/internal/sqlparser  0.577s
	//
	// PASS
	// ok      github.com/pressly/goose/v4/internal/sqlparser  0.349s
	//
	// ?       github.com/pressly/goose/v4/internal/check      [no test files]
	//
	// testing: warning: no tests to run
	// PASS
	// ok      github.com/pressly/goose/v4/pkg/source  0.382s [no tests to run]
	//
	// We modify this output slightly so it's more consistent and easier to parse.
	fmt.Fprintf(w, "[%s]\t%10s\t%s%s\n",
		strings.ToUpper(action.String()),
		strconv.FormatFloat(e.Elapsed, 'f', 2, 64)+"s",
		e.Package,
		suffix,
	)
}

type GoTestSummary struct {
	Packages map[string]*Package
}

func (s *GoTestSummary) AddRawEvent(str string) {
	if strings.HasPrefix(str, "FAIL") {
		ss := failedBuildOrSetupRe.FindStringSubmatch(str)
		if len(ss) == 3 {
			pkgName, failMessage := strings.TrimSpace(ss[1]), strings.TrimSpace(ss[2])
			pkg, ok := s.Packages[pkgName]
			if !ok {
				pkg = newPackage()
				s.Packages[pkgName] = pkg
			}
			pkg.Summary.Package = pkgName
			pkg.Summary.Action = ActionFail
			pkg.Summary.Output = failMessage
			pkg.HasFailedBuildOrSetup = true
		}
	}
}

func (s *GoTestSummary) AddEvent(e *Event) {
	// Discard noisy output such as "=== CONT", "=== RUN", etc. These add
	// no value to the go test output, unless you care to follow how often
	// tests are paused and for what duration.
	if e.Action == ActionOutput && e.DiscardOutput() {
		return
	}
	pkg, ok := s.Packages[e.Package]
	if !ok {
		pkg = newPackage()
		s.Packages[e.Package] = pkg
	}
	// Capture the start time of the package. This is only available in go1.20 and above.
	if e.Action == ActionStart {
		pkg.StartTime = e.Time
		return
	}
	// Special case panics.
	if e.IsPanic() {
		pkg.HasPanic = true
		pkg.Summary.Action = ActionFail
		pkg.Summary.Package = e.Package
		pkg.Summary.Test = e.Test
	}
	// Short circuit output when panic is detected.
	if pkg.HasPanic {
		pkg.PanicEvents = append(pkg.PanicEvents, e)
		return
	}
	if e.LastLine() {
		pkg.Summary = e
		return
	}
	// Parse the raw output to add additional metadata to Package.
	switch {
	case e.IsRace():
		pkg.HasDataRace = true
		if e.Test != "" {
			pkg.DataRaceTests = append(pkg.DataRaceTests, e.Test)
		}
	case e.IsCached():
		pkg.Cached = true
	case e.NoTestFiles():
		pkg.NoTestFiles = true
		// Manually mark [no test files] as "pass", because the go test tool reports the
		// package Summary action as "skip".
		// TODO(mf): revisit this behavior?
		pkg.Summary.Package = e.Package
		pkg.Summary.Action = ActionPass
	case e.NoTestsWarn():
		// One or more tests within the package contains no tests.
		pkg.NoTestSlice = append(pkg.NoTestSlice, e)
	case e.NoTestsToRun():
		// Only packages marked as "pass" will contain a summary line appended with [no tests to run].
		// This indicates one or more tests is marked as having no tests to run.
		pkg.NoTests = true
		pkg.Summary.Package = e.Package
		pkg.Summary.Action = ActionPass
	default:
		if cover, ok := e.Cover(); ok {
			pkg.Cover = true
			pkg.Coverage = cover
		}
	}
	// We captured all the necessary package-level information, if the event
	// is output and does not have a test name, discard it.
	if e.DiscardEmptyTestOutput() {
		return
	}
	pkg.AddEvent(e)
}

func (s *GoTestSummary) GetSortedPackages(sorter PackageSorter) []*Package {
	packages := make([]*Package, 0, len(s.Packages))
	for _, pkg := range s.Packages {
		packages = append(packages, pkg)
	}
	sort.Sort(sorter(packages))
	return packages
}

func (s *GoTestSummary) ExitCode() int {
	for _, pkg := range s.Packages {
		switch {
		case pkg.HasFailedBuildOrSetup:
			return 2
		case pkg.HasPanic, pkg.HasDataRace:
			return 1
		case len(pkg.DataRaceTests) > 0:
			return 1
		case pkg.Summary.Action == ActionFail:
			return 1
		}
	}
	return 0
}