Skip to content

ci: Print go test stats #6855

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,14 @@ jobs:
echo "cover=false" >> $GITHUB_OUTPUT
fi

gotestsum --junitfile="gotests.xml" --packages="./..." -- -parallel=8 -timeout=7m -short -failfast $COVERAGE_FLAGS
gotestsum --junitfile="gotests.xml" --jsonfile="gotests.json" --packages="./..." -- -parallel=8 -timeout=7m -short -failfast $COVERAGE_FLAGS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we run it with PostgreSQL too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do, see Makefile.


- name: Print test stats
if: success() || failure()
run: |
# Artifacts are not available after rerunning a job,
# so we need to print the test stats to the log.
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json

- uses: actions/upload-artifact@v3
if: success() || failure()
Expand Down Expand Up @@ -369,6 +376,13 @@ jobs:
run: |
make test-postgres

- name: Print test stats
if: success() || failure()
run: |
# Artifacts are not available after rerunning a job,
# so we need to print the test stats to the log.
go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json

- uses: actions/upload-artifact@v3
if: success() || failure()
with:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ extend-exclude = [
# These files contain base64 strings that confuse the detector
"**XService**.ts",
"**identity.go",
"scripts/ci-report/testdata/**",
]
8 changes: 4 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
**/*.swp
gotests.coverage
gotests.xml
gotestsum.json
gotests_stats.json
gotests.json
node_modules/
vendor/
yarn-error.log
Expand All @@ -29,9 +30,8 @@ site/e2e/states/*.json
site/playwright-report/*
site/.swc

# Make target for updating golden files.
cli/testdata/.gen-golden
helm/tests/testdata/.gen-golden
# Make target for updating golden files (any dir).
.gen-golden

# Build
/build/
Expand Down
8 changes: 4 additions & 4 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
**/*.swp
gotests.coverage
gotests.xml
gotestsum.json
gotests_stats.json
gotests.json
node_modules/
vendor/
yarn-error.log
Expand All @@ -32,9 +33,8 @@ site/e2e/states/*.json
site/playwright-report/*
site/.swc

# Make target for updating golden files.
cli/testdata/.gen-golden
helm/tests/testdata/.gen-golden
# Make target for updating golden files (any dir).
.gen-golden

# Build
/build/
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS)
./scripts/apidocgen/generate.sh
yarn run --cwd=site format:write:only ../docs/api ../docs/manifest.json ../coderd/apidoc/swagger.json

update-golden-files: cli/testdata/.gen-golden helm/tests/testdata/.gen-golden
update-golden-files: cli/testdata/.gen-golden helm/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden
.PHONY: update-golden-files

cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES)
Expand All @@ -525,6 +525,10 @@ helm/tests/testdata/.gen-golden: $(wildcard helm/tests/testdata/*.golden) $(GO_S
go test ./helm/tests -run=TestUpdateGoldenFiles -update
touch "$@"

scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
touch "$@"

# Generate a prettierrc for the site package that uses relative paths for
# overrides. This allows us to share the same prettier config between the
# site and the root of the repo.
Expand Down Expand Up @@ -596,6 +600,7 @@ test-postgres: test-clean test-postgres-docker
# more consistent execution.
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
--junitfile="gotests.xml" \
--jsonfile="gotests.json" \
--packages="./..." -- \
-covermode=atomic -coverprofile="gotests.coverage" -timeout=20m \
-parallel=4 \
Expand Down
7 changes: 7 additions & 0 deletions scripts/ci-report/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# ci-report

This program generates a CI report from the `gotests.json` generated by `go test -json` (we use `gotestsum` as a frontend).

## Limitations

We won't generate any report/stats for tests that weren't run. To find all existing tests, we could use: `go test ./... -list=. -json`, but the time it takes is probably not worth it. Usually most tests will run, even if there are errors and we're using `-failfast`.
258 changes: 258 additions & 0 deletions scripts/ci-report/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package main

import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"golang.org/x/exp/slices"
"golang.org/x/xerrors"
)

func main() {
if len(os.Args) != 2 {
_, _ = fmt.Println("usage: ci-report <gotests.json>")
os.Exit(1)
}
name := os.Args[1]

goTests, err := parseGoTestJSON(name)
if err != nil {
_, _ = fmt.Printf("error parsing gotestsum report: %v", err)
os.Exit(1)
}

rep, err := parseCIReport(goTests)
if err != nil {
_, _ = fmt.Printf("error parsing ci report: %v", err)
os.Exit(1)
}

err = printCIReport(os.Stdout, rep)
if err != nil {
_, _ = fmt.Printf("error printing report: %v", err)
os.Exit(1)
}
}

func parseGoTestJSON(name string) (GotestsumReport, error) {
f, err := os.Open(name)
if err != nil {
return GotestsumReport{}, xerrors.Errorf("error opening gotestsum json file: %w", err)
}
defer f.Close()

dec := json.NewDecoder(f)
var report GotestsumReport
for {
var e GotestsumReportEntry
err = dec.Decode(&e)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return GotestsumReport{}, xerrors.Errorf("error decoding json: %w", err)
}
e.Package = strings.TrimPrefix(e.Package, "github.com/coder/coder/")
report = append(report, e)
}

return report, nil
}

func parseCIReport(report GotestsumReport) (CIReport, error) {
packagesSortedByName := []string{}
packageTimes := map[string]float64{}
packageFail := map[string]int{}
packageSkip := map[string]bool{}
testTimes := map[string]float64{}
testSkip := map[string]bool{}
testOutput := map[string]string{}
testSortedByName := []string{}
timeouts := map[string]string{}
timeoutRunningTests := map[string]bool{}
for i, e := range report {
switch e.Action {
// A package/test may fail or pass.
case Fail:
if e.Test == "" {
packageTimes[e.Package] = *e.Elapsed
} else {
packageFail[e.Package]++
name := e.Package + "." + e.Test
testTimes[name] = *e.Elapsed
}
case Pass:
if e.Test == "" {
packageTimes[e.Package] = *e.Elapsed
} else {
name := e.Package + "." + e.Test
delete(testOutput, name)
testTimes[name] = *e.Elapsed
}

// Gather all output (deleted when irrelevant).
case Output:
name := e.Package + "." + e.Test // May be pkg.Test or pkg.
if _, ok := timeouts[name]; ok || strings.HasPrefix(e.Output, "panic: test timed out") {
timeouts[name] += e.Output
continue
}
if e.Test != "" {
name := e.Package + "." + e.Test
testOutput[name] += e.Output
}

// Packages start, tests run and either may be skipped.
case Start:
packagesSortedByName = append(packagesSortedByName, e.Package)
case Run:
name := e.Package + "." + e.Test
testSortedByName = append(testSortedByName, name)
case Skip:
if e.Test == "" {
packageSkip[e.Package] = true
} else {
name := e.Package + "." + e.Test
testSkip[name] = true
delete(testOutput, name)
}

// Ignore.
case Cont:
case Pause:

default:
return CIReport{}, xerrors.Errorf("unknown action: %v in entry %d (%v)", e.Action, i, e)
}
}

// Normalize timeout from "pkg." or "pkg.Test" to "pkg".
timeoutsNorm := make(map[string]string)
for k, v := range timeouts {
names := strings.SplitN(k, ".", 2)
pkg := names[0]
if _, ok := timeoutsNorm[pkg]; ok {
panic("multiple timeouts for package: " + pkg)
}
timeoutsNorm[pkg] = v

// Mark all running tests as timed out.
// panic: test timed out after 2s\nrunning tests:\n\tTestAgent_Session_TTY_Hushlogin (0s)\n\n ...
parts := strings.SplitN(v, "\n", 3)
if len(parts) == 3 && strings.HasPrefix(parts[1], "running tests:") {
s := bufio.NewScanner(strings.NewReader(parts[2]))
for s.Scan() {
name := s.Text()
if !strings.HasPrefix(name, "\tTest") {
break
}
name = strings.TrimPrefix(name, "\t")
name = strings.SplitN(name, " ", 2)[0]
timeoutRunningTests[pkg+"."+name] = true
packageFail[pkg]++
}
}
}
timeouts = timeoutsNorm

sortAZ := func(a, b string) bool { return a < b }
slices.SortFunc(packagesSortedByName, sortAZ)
slices.SortFunc(testSortedByName, sortAZ)

var rep CIReport

for _, pkg := range packagesSortedByName {
output, timeout := timeouts[pkg]
rep.Packages = append(rep.Packages, PackageReport{
Name: pkg,
Time: packageTimes[pkg],
Skip: packageSkip[pkg],
Fail: packageFail[pkg] > 0,
Timeout: timeout,
Output: output,
NumFailed: packageFail[pkg],
})
}

for _, test := range testSortedByName {
names := strings.SplitN(test, ".", 2)
skip := testSkip[test]
out, fail := testOutput[test]
rep.Tests = append(rep.Tests, TestReport{
Package: names[0],
Name: names[1],
Time: testTimes[test],
Skip: skip,
Fail: fail,
Timeout: timeoutRunningTests[test],
Output: out,
})
}

return rep, nil
}

func printCIReport(dst io.Writer, rep CIReport) error {
enc := json.NewEncoder(dst)
enc.SetIndent("", " ")
err := enc.Encode(rep)
if err != nil {
return xerrors.Errorf("error encoding json: %w", err)
}
return nil
}

type CIReport struct {
Packages []PackageReport `json:"packages"`
Tests []TestReport `json:"tests"`
}

type PackageReport struct {
Name string `json:"name"`
Time float64 `json:"time"`
Skip bool `json:"skip,omitempty"`
Fail bool `json:"fail,omitempty"`
NumFailed int `json:"num_failed,omitempty"`
Timeout bool `json:"timeout,omitempty"`
Output string `json:"output,omitempty"` // Output present e.g. for timeout.
}

type TestReport struct {
Package string `json:"package"`
Name string `json:"name"`
Time float64 `json:"time"`
Skip bool `json:"skip,omitempty"`
Fail bool `json:"fail,omitempty"`
Timeout bool `json:"timeout,omitempty"`
Output string `json:"output,omitempty"`
}

type GotestsumReport []GotestsumReportEntry

type GotestsumReportEntry struct {
Time time.Time `json:"Time"`
Action Action `json:"Action"`
Package string `json:"Package"`
Test string `json:"Test,omitempty"`
Output string `json:"Output,omitempty"`
Elapsed *float64 `json:"Elapsed,omitempty"`
}

type Action string

const (
Cont Action = "cont"
Fail Action = "fail"
Output Action = "output"
Pass Action = "pass"
Pause Action = "pause"
Run Action = "run"
Skip Action = "skip"
Start Action = "start"
)
Loading