cmd/test2json2gha/event.go (165 lines of code) (raw):

package main import ( "io" "iter" "log/slog" "math" "os" "path" "strings" "time" "github.com/Azure/dalec" "github.com/pkg/errors" ) const ( pass = "pass" fail = "fail" skip = "skip" ) // TestEvent is the go test2json event data structure we receive from `go test` // This is defined in https://pkg.go.dev/cmd/test2json#hdr-Output_Format type TestEvent struct { Time time.Time Action string Package string Test string Elapsed float64 // seconds Output string } type EventHandler interface { HandleEvent(te *TestEvent) error } type ResultsFormatter interface { FormatResults(results iter.Seq[*TestResult], out io.Writer) error } // outputStreamer is an [EventHandler] that writes the test output to the console // This allows receiving the test output in real time type outputStreamer struct { out io.Writer } func (h *outputStreamer) HandleEvent(te *TestEvent) error { if te.Output != "" { _, err := h.out.Write([]byte(te.Output)) return err } return nil } // resultsHandler is an [EventHandler] that gathers all the results from every event // handled. type resultsHandler struct { results map[string]*TestResult } func (h *resultsHandler) getOutputStream(te *TestEvent) (*TestResult, error) { key := path.Join(te.Package, te.Test) tr := h.results[key] if tr != nil { return tr, nil } f, err := os.CreateTemp("", strings.ReplaceAll(key, "/", "-")) if err != nil { return nil, errors.WithStack(err) } tr = &TestResult{output: f, pkg: te.Package, name: te.Test} if h.results == nil { h.results = make(map[string]*TestResult) } h.results[key] = tr return tr, nil } func (h *resultsHandler) HandleEvent(te *TestEvent) error { tr, err := h.getOutputStream(te) if err != nil { return err } switch te.Action { case fail: tr.failed = true case skip: tr.skipped = true } tr.elapsed = te.Elapsed if te.Output != "" { _, err := tr.output.WriteString(te.Output) if err != nil { return errors.Wrap(err, "error collecting test event output") } } return nil } func (h *resultsHandler) Results() iter.Seq[*TestResult] { keys := dalec.SortMapKeys(h.results) return func(yield func(*TestResult) bool) { for _, key := range keys { tr := h.results[key] if !yield(tr) { break } } } } func (h *resultsHandler) Close() { for _, tr := range h.results { tr.Close() } } // WriteLogs writes the test logs to the specified directory func (h *resultsHandler) WriteLogs(dir string) { for r := range h.Results() { name := strings.ReplaceAll(r.name, "/", "_") + ".txt" fullPath := path.Join(dir, r.pkg, name) log, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { if !errors.Is(err, os.ErrNotExist) { slog.Error("Error opening test log file", "error", err) continue } if err := os.MkdirAll(path.Dir(fullPath), 0755); err != nil { slog.Error("Error creating test log directory", "error", err) continue } log, err = os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { slog.Error("Error opening test log file", "error", err) continue } } // Here will intentionally use the original *os.File instead of calling r.Reader() // This allows potential optimizations in `io.Copy` to avoid actually copying data in userspace. rdr, err := os.Open(r.output.Name()) if err != nil { slog.Error("Error opening test log file", "error", err) log.Close() continue } _, err = io.Copy(log, rdr) log.Close() rdr.Close() if err != nil { slog.Error("Error writing test log file", "error", err) continue } } } func (h *resultsHandler) Cleanup() { h.Close() for _, tr := range h.results { if err := os.Remove(tr.output.Name()); err != nil { if errors.Is(err, os.ErrNotExist) { continue } slog.Error("Error removing test log file", "error", err) } } } // TestResult is where we collect all the data about a test type TestResult struct { output *os.File pkg string name string failed bool skipped bool elapsed float64 } // [Close] closes the underlying output file and invalidates any readers // created from [TestResult.Reader]. func (r *TestResult) Close() { r.output.Close() } // Reader creates a new reader that contains all the test output // Calling [TestResult.Reader] multiple times will return a new, independent reader // each time. // // Calling [TestResult.Close] will close the underlying file, any readers created before or // after will be invalid after that and should return an [io.EOF] error on read. func (r *TestResult) Reader() *io.SectionReader { return io.NewSectionReader(r.output, 0, math.MaxInt64) } // checkFailed is an [EventHandler] that checks if any test has failed // and sets the [checkFailed] to true if so. type checkFailed bool func (c *checkFailed) HandleEvent(te *TestEvent) error { if te.Action == "fail" { *c = true } return nil }