diff --git a/.github/actions/go-test-setup/action.yml b/.github/actions/go-test-setup/action.yml index c7e4d11ac5..2667db4c9d 100644 --- a/.github/actions/go-test-setup/action.yml +++ b/.github/actions/go-test-setup/action.yml @@ -9,3 +9,9 @@ runs: shell: bash # This matches only tests with "NoCover" in their test name to avoid running all tests again. run: go test -tags nocover -run NoCover -v ./... + - name: Install testing tools + shell: bash + run: cd scripts/test_analysis && go install ./cmd/gotest2sql + - name: Install test_analysis + shell: bash + run: cd scripts/test_analysis && go install . diff --git a/.github/workflows/go-check.yml b/.github/workflows/go-check.yml index a1cc205f72..617f59e480 100644 --- a/.github/workflows/go-check.yml +++ b/.github/workflows/go-check.yml @@ -17,4 +17,5 @@ jobs: go-check: uses: ipdxco/unified-github-workflows/.github/workflows/go-check.yml@v1.0 with: + go-version: "1.22.x" go-generate-ignore-protoc-version-comments: true diff --git a/.github/workflows/go-test-template.yml b/.github/workflows/go-test-template.yml new file mode 100644 index 0000000000..fa7c974ea2 --- /dev/null +++ b/.github/workflows/go-test-template.yml @@ -0,0 +1,154 @@ +name: Go Test +on: + workflow_call: + inputs: + go-versions: + required: false + type: string + default: '["this", "next"]' + secrets: + CODECOV_TOKEN: + required: false + +defaults: + run: + shell: bash + +jobs: + unit: + strategy: + fail-fast: false + matrix: + os: ["ubuntu", "macos", "windows"] + go: ${{ fromJSON(inputs.go-versions) }} + env: + GOTESTFLAGS: -cover -coverprofile=module-coverage.txt -coverpkg=./... + GO386FLAGS: "" + GORACEFLAGS: "" + runs-on: ${{ fromJSON(vars[format('UCI_GO_TEST_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }} + name: ${{ matrix.os }} (go ${{ matrix.go }}) + steps: + - name: Use msys2 on windows + if: matrix.os == 'windows' + # The executable for msys2 is also called bash.cmd + # https://github.com/actions/virtual-environments/blob/main/images/win/Windows2019-Readme.md#shells + # If we prepend its location to the PATH + # subsequent 'shell: bash' steps will use msys2 instead of gitbash + run: echo "C:/msys64/usr/bin" >> $GITHUB_PATH + - name: Check out the repository + uses: actions/checkout@v4 + with: + submodules: recursive + - name: Check out the latest stable version of Go + uses: actions/setup-go@v5 + with: + go-version: stable + cache: ${{ matrix.os != 'windows' }} # Windows VMs are slow to use caching. Can add ~15m to the job + - name: Read the Unified GitHub Workflows configuration + id: config + uses: ipdxco/unified-github-workflows/.github/actions/read-config@main + - name: Read the go.mod file + id: go-mod + uses: ipdxco/unified-github-workflows/.github/actions/read-go-mod@main + - name: Determine the Go version to use based on the go.mod file + id: go + env: + MATRIX_GO: ${{ matrix.go }} + GO_MOD_VERSION: ${{ fromJSON(steps.go-mod.outputs.json).Go }} + run: | + if [[ "$MATRIX_GO" == "this" ]]; then + echo "version=$GO_MOD_VERSION.x" >> $GITHUB_OUTPUT + elif [[ "$MATRIX_GO" == "next" ]]; then + MAJOR="${GO_MOD_VERSION%.[0-9]*}" + MINOR="${GO_MOD_VERSION#[0-9]*.}" + echo "version=$MAJOR.$(($MINOR+1)).x" >> $GITHUB_OUTPUT + elif [[ "$MATRIX_GO" == "prev" ]]; then + MAJOR="${GO_MOD_VERSION%.[0-9]*}" + MINOR="${GO_MOD_VERSION#[0-9]*.}" + echo "version=$MAJOR.$(($MINOR-1)).x" >> $GITHUB_OUTPUT + else + echo "version=$MATRIX_GO" >> $GITHUB_OUTPUT + fi + - name: Enable shuffle flag for go test command + if: toJSON(fromJSON(steps.config.outputs.json).shuffle) != 'false' + run: | + echo "GOTESTFLAGS=-shuffle=on $GOTESTFLAGS" >> $GITHUB_ENV + echo "GO386FLAGS=-shuffle=on $GO386FLAGS" >> $GITHUB_ENV + echo "GORACEFLAGS=-shuffle=on $GORACEFLAGS" >> $GITHUB_ENV + - name: Enable verbose flag for go test command + if: toJSON(fromJSON(steps.config.outputs.json).verbose) != 'false' + run: | + echo "GOTESTFLAGS=-v $GOTESTFLAGS" >> $GITHUB_ENV + echo "GO386FLAGS=-v $GO386FLAGS" >> $GITHUB_ENV + echo "GORACEFLAGS=-v $GORACEFLAGS" >> $GITHUB_ENV + - name: Set extra flags for go test command + if: fromJSON(steps.config.outputs.json).gotestflags != '' + run: | + echo "GOTESTFLAGS=${{ fromJSON(steps.config.outputs.json).gotestflags }} $GOTESTFLAGS" >> $GITHUB_ENV + - name: Set extra flags for go test race command + if: fromJSON(steps.config.outputs.json).goraceflags != '' + run: | + echo "GORACEFLAGS=${{ fromJSON(steps.config.outputs.json).goraceflags }} $GORACEFLAGS" >> $GITHUB_ENV + - name: Set up the Go version read from the go.mod file + uses: actions/setup-go@v5 + with: + go-version: ${{ steps.go.outputs.version }} + cache: ${{ matrix.os != 'windows' }} # Windows VMs are slow to use caching. Can add ~15m to the job + - name: Display the Go version and environment + run: | + go version + go env + - name: Run repo-specific setup + uses: ./.github/actions/go-test-setup + if: hashFiles('./.github/actions/go-test-setup') != '' + - name: Run tests + id: test + if: contains(fromJSON(steps.config.outputs.json).skipOSes, matrix.os) == false + uses: protocol/multiple-go-modules@v1.4 + with: + run: test_analysis ${{ env.GOTESTFLAGS }} + - name: Upload test results + if: always() + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.os }}_${{ matrix.go }}_test_results.db + path: ./test_results.db + - name: Add failure summary + if: always() + run: | + echo "### Failure Summary" >> $GITHUB_STEP_SUMMARY + test_analysis summarize >> $GITHUB_STEP_SUMMARY + - name: Remove test results + run: rm ./test_results.db + - name: Run tests with race detector + # speed things up. Windows and OSX VMs are slow + if: matrix.os == 'ubuntu' && + fromJSON(steps.config.outputs.json).skipRace != true && + contains(fromJSON(steps.config.outputs.json).skipOSes, matrix.os) == false + uses: protocol/multiple-go-modules@v1.4 + id: race + with: + run: test_analysis -race ${{ env.GORACEFLAGS }} ./... + - name: Upload test results (Race) + if: (steps.race.conclusion == 'success' || steps.race.conclusion == 'failure') + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.os }}_${{ matrix.go }}_test_results_race.db + path: ./test_results.db + - name: Add failure summary + if: (steps.race.conclusion == 'success' || steps.race.conclusion == 'failure') + run: | + echo "# Tests with race detector failure summary" >> $GITHUB_STEP_SUMMARY + test_analysis summarize >> $GITHUB_STEP_SUMMARY + - name: Adding Link to Run Analysis + run: echo "### [Test flakiness analysis](https://observablehq.com/d/d74435ea5bbf24c7?run-id=$GITHUB_RUN_ID)" >> $GITHUB_STEP_SUMMARY + - name: Collect coverage files + id: coverages + run: echo "files=$(find . -type f -name 'module-coverage.txt' | tr -s '\n' ',' | sed 's/,$//')" >> $GITHUB_OUTPUT + - name: Upload coverage to Codecov + uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0 + with: + files: ${{ steps.coverages.outputs.files }} + env_vars: OS=${{ matrix.os }}, GO=${{ steps.go.outputs.version }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index e33480c0f8..2ae84343f6 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -15,7 +15,7 @@ concurrency: jobs: go-test: - uses: libp2p/uci/.github/workflows/go-test.yml@v1.0 + uses: ./.github/workflows/go-test-template.yml with: go-versions: '["1.21.x", "1.22.x"]' secrets: diff --git a/libp2p_test.go b/libp2p_test.go index b479ff2b7d..7ca7850854 100644 --- a/libp2p_test.go +++ b/libp2p_test.go @@ -5,6 +5,8 @@ import ( "crypto/rand" "errors" "fmt" + "net" + "net/netip" "regexp" "strconv" "strings" @@ -488,10 +490,23 @@ func TestHostAddrsFactoryAddsCerthashes(t *testing.T) { h.Close() } +func newRandomPort(t *testing.T) string { + t.Helper() + // Find an available port + c, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + c.LocalAddr().Network() + ipPort := netip.MustParseAddrPort(c.LocalAddr().String()) + port := strconv.Itoa(int(ipPort.Port())) + require.NoError(t, c.Close()) + return port +} + func TestWebRTCReuseAddrWithQUIC(t *testing.T) { + port := newRandomPort(t) order := [][]string{ - {"/ip4/127.0.0.1/udp/54322/quic-v1", "/ip4/127.0.0.1/udp/54322/webrtc-direct"}, - {"/ip4/127.0.0.1/udp/54322/webrtc-direct", "/ip4/127.0.0.1/udp/54322/quic-v1"}, + {"/ip4/127.0.0.1/udp/" + port + "/quic-v1", "/ip4/127.0.0.1/udp/" + port + "/webrtc-direct"}, + {"/ip4/127.0.0.1/udp/" + port + "/webrtc-direct", "/ip4/127.0.0.1/udp/" + port + "/quic-v1"}, // We do not support WebRTC automatically reusing QUIC addresses if port is not specified, yet. // {"/ip4/127.0.0.1/udp/0/webrtc-direct", "/ip4/127.0.0.1/udp/0/quic-v1"}, } @@ -542,16 +557,18 @@ func TestWebRTCReuseAddrWithQUIC(t *testing.T) { }) } - swapPort := func(addrStrs []string, newPort string) []string { + swapPort := func(addrStrs []string, oldPort, newPort string) []string { out := make([]string, 0, len(addrStrs)) for _, addrStr := range addrStrs { - out = append(out, strings.Replace(addrStr, "54322", newPort, 1)) + out = append(out, strings.Replace(addrStr, oldPort, newPort, 1)) } return out } t.Run("setup with no reuseport. Should fail", func(t *testing.T) { - h1, err := New(ListenAddrStrings(swapPort(order[0], "54323")...), Transport(quic.NewTransport), Transport(libp2pwebrtc.New), QUICReuse(quicreuse.NewConnManager, quicreuse.DisableReuseport())) + oldPort := port + newPort := newRandomPort(t) + h1, err := New(ListenAddrStrings(swapPort(order[0], oldPort, newPort)...), Transport(quic.NewTransport), Transport(libp2pwebrtc.New), QUICReuse(quicreuse.NewConnManager, quicreuse.DisableReuseport())) require.NoError(t, err) // It's a bug/feature that swarm.Listen does not error if at least one transport succeeds in listening. defer h1.Close() // Check that webrtc did fail to listen @@ -560,7 +577,9 @@ func TestWebRTCReuseAddrWithQUIC(t *testing.T) { }) t.Run("setup with autonat", func(t *testing.T) { - h1, err := New(EnableAutoNATv2(), ListenAddrStrings(swapPort(order[0], "54324")...), Transport(quic.NewTransport), Transport(libp2pwebrtc.New), QUICReuse(quicreuse.NewConnManager, quicreuse.DisableReuseport())) + oldPort := port + newPort := newRandomPort(t) + h1, err := New(EnableAutoNATv2(), ListenAddrStrings(swapPort(order[0], oldPort, newPort)...), Transport(quic.NewTransport), Transport(libp2pwebrtc.New), QUICReuse(quicreuse.NewConnManager, quicreuse.DisableReuseport())) require.NoError(t, err) // It's a bug/feature that swarm.Listen does not error if at least one transport succeeds in listening. defer h1.Close() // Check that webrtc did fail to listen diff --git a/p2p/protocol/identify/id.go b/p2p/protocol/identify/id.go index 574cd27a68..acaa40aca3 100644 --- a/p2p/protocol/identify/id.go +++ b/p2p/protocol/identify/id.go @@ -995,7 +995,7 @@ func (ids *idService) addConnWithLock(c network.Conn) { } func signedPeerRecordFromMessage(msg *pb.Identify) (*record.Envelope, error) { - if msg.SignedPeerRecord == nil || len(msg.SignedPeerRecord) == 0 { + if len(msg.SignedPeerRecord) == 0 { return nil, nil } env, _, err := record.ConsumeEnvelope(msg.SignedPeerRecord, peer.PeerRecordEnvelopeDomain) diff --git a/p2p/protocol/identify/id_test.go b/p2p/protocol/identify/id_test.go index 904e47cece..06ea0119df 100644 --- a/p2p/protocol/identify/id_test.go +++ b/p2p/protocol/identify/id_test.go @@ -960,7 +960,7 @@ func waitForAddrInStream(t *testing.T, s <-chan ma.Multiaddr, expected ma.Multia } continue case <-time.After(timeout): - t.Fatalf(failMsg) + t.Fatal(failMsg) } } } diff --git a/p2p/transport/quic/cmd/client/main.go b/p2p/transport/quic/cmd/client/main.go index 5736d89551..e9883d2a1d 100644 --- a/p2p/transport/quic/cmd/client/main.go +++ b/p2p/transport/quic/cmd/client/main.go @@ -14,6 +14,6 @@ func main() { return } if err := cmdlib.RunClient(os.Args[1], os.Args[2]); err != nil { - log.Fatalf(err.Error()) + log.Fatal(err.Error()) } } diff --git a/p2p/transport/quic/cmd/server/main.go b/p2p/transport/quic/cmd/server/main.go index ef3f90a4df..c478d34b22 100644 --- a/p2p/transport/quic/cmd/server/main.go +++ b/p2p/transport/quic/cmd/server/main.go @@ -14,6 +14,6 @@ func main() { return } if err := cmdlib.RunServer(os.Args[1], nil); err != nil { - log.Fatalf(err.Error()) + log.Fatal(err.Error()) } } diff --git a/scripts/test_analysis/cmd/gotest2sql/main.go b/scripts/test_analysis/cmd/gotest2sql/main.go new file mode 100644 index 0000000000..05a247e2d8 --- /dev/null +++ b/scripts/test_analysis/cmd/gotest2sql/main.go @@ -0,0 +1,100 @@ +// gotest2sql inserts the output of go test -json ./... into a sqlite database +package main + +import ( + "bufio" + "database/sql" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "time" + + _ "github.com/glebarez/go-sqlite" +) + +type TestEvent struct { + Time time.Time // encodes as an RFC3339-format string + Action string + Package string + Test string + Elapsed float64 // seconds + Output string +} + +func main() { + outputPath := flag.String("output", "", "output db file") + verbose := flag.Bool("v", false, "Print test output to stdout") + flag.Parse() + + if *outputPath == "" { + log.Fatal("-output path is required") + } + + db, err := sql.Open("sqlite", *outputPath) + if err != nil { + log.Fatal(err) + } + + // Create a table to store test results. + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS test_results ( + Time TEXT, + Action TEXT, + Package TEXT, + Test TEXT, + Elapsed REAL, + Output TEXT, + BatchInsertTime TEXT + )`) + if err != nil { + log.Fatal(err) + } + + tx, err := db.Begin() + if err != nil { + log.Fatal(err) + } + + // Prepare the insert statement once + insertTime := time.Now().Format(time.RFC3339Nano) + stmt, err := tx.Prepare(` + INSERT INTO test_results (Time, Action, Package, Test, Elapsed, Output, BatchInsertTime) + VALUES (?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + log.Fatal(err) + } + defer stmt.Close() // Ensure the statement is closed after use + + s := bufio.NewScanner(os.Stdin) + for s.Scan() { + line := s.Bytes() + var ev TestEvent + err = json.Unmarshal(line, &ev) + if err != nil { + log.Fatal(err) + } + if *verbose && ev.Action == "output" { + fmt.Print(ev.Output) + } + + _, err = stmt.Exec( + ev.Time.Format(time.RFC3339Nano), + ev.Action, + ev.Package, + ev.Test, + ev.Elapsed, + ev.Output, + insertTime, + ) + if err != nil { + log.Fatal(err) + } + } + + // Commit the transaction + if err := tx.Commit(); err != nil { + log.Fatal(err) + } +} diff --git a/scripts/test_analysis/go.mod b/scripts/test_analysis/go.mod new file mode 100644 index 0000000000..3fcf14bfa7 --- /dev/null +++ b/scripts/test_analysis/go.mod @@ -0,0 +1,17 @@ +module github.com/libp2p/go-libp2p/scripts/test_analysis + +go 1.22.1 + +require github.com/glebarez/go-sqlite v1.22.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.15.0 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect +) diff --git a/scripts/test_analysis/go.sum b/scripts/test_analysis/go.sum new file mode 100644 index 0000000000..e635754c5a --- /dev/null +++ b/scripts/test_analysis/go.sum @@ -0,0 +1,23 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= diff --git a/scripts/test_analysis/main.go b/scripts/test_analysis/main.go new file mode 100644 index 0000000000..a2da681bf8 --- /dev/null +++ b/scripts/test_analysis/main.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "os" + "os/exec" + "regexp" + "strings" + + _ "github.com/glebarez/go-sqlite" +) + +const dbPath = "./test_results.db" +const retryCount = 4 // For a total of 5 runs + +var coverRegex = regexp.MustCompile(`-cover`) + +func main() { + var t tester + if len(os.Args) >= 2 { + if os.Args[1] == "summarize" { + md, err := t.summarize() + if err != nil { + log.Fatal(err) + } + fmt.Print(md) + return + } + } + + passThruFlags := os.Args[1:] + err := t.runTests(passThruFlags) + if err != nil { + log.Fatal(err) + } +} + +type tester struct { + Dir string +} + +func (t *tester) runTests(passThruFlags []string) error { + err := t.goTestAll(passThruFlags) + if err == nil { + // No failed tests, nothing to do + return nil + } + log.Printf("Not all tests passed: %v", err) + + failedTests, err := t.findFailedTests(context.Background()) + if err != nil { + return err + } + + log.Printf("Found %d failed tests. Retrying them %d times", len(failedTests), retryCount) + hasOneNonFlakyFailure := false + loggedFlaky := map[string]struct{}{} + + for _, ft := range failedTests { + isFlaky := false + for i := 0; i < retryCount; i++ { + log.Printf("Retrying %s.%s", ft.Package, ft.Test) + if err := t.goTestPkgTest(ft.Package, ft.Test, filterOutFlags(passThruFlags, coverRegex)); err != nil { + log.Printf("Failed to run %s.%s: %v", ft.Package, ft.Test, err) + } else { + isFlaky = true + flakyName := ft.Package + "." + ft.Test + if _, ok := loggedFlaky[flakyName]; !ok { + loggedFlaky[flakyName] = struct{}{} + log.Printf("Test %s.%s is flaky.", ft.Package, ft.Test) + } + } + } + if !isFlaky { + hasOneNonFlakyFailure = true + } + } + + // A test consistently failed, so we should exit with a non-zero exit code. + if hasOneNonFlakyFailure { + return errors.New("one or more tests consistently failed") + } + return nil +} + +func (t *tester) goTestAll(extraFlags []string) error { + flags := []string{"./..."} + flags = append(flags, extraFlags...) + return t.goTest(flags) +} + +func (t *tester) goTestPkgTest(pkg, testname string, extraFlags []string) error { + flags := []string{ + pkg, "-run", "^" + testname + "$", "-count", "1", + } + flags = append(flags, extraFlags...) + return t.goTest(flags) +} + +func (t *tester) goTest(extraFlags []string) error { + flags := []string{ + "test", "-json", + } + flags = append(flags, extraFlags...) + cmd := exec.Command("go", flags...) + cmd.Dir = t.Dir + cmd.Stderr = os.Stderr + + gotest2sql := exec.Command("gotest2sql", "-v", "-output", dbPath) + gotest2sql.Dir = t.Dir + gotest2sql.Stdin, _ = cmd.StdoutPipe() + gotest2sql.Stdout = os.Stdout + gotest2sql.Stderr = os.Stderr + err := gotest2sql.Start() + if err != nil { + return err + } + + err = cmd.Run() + return errors.Join(err, gotest2sql.Wait()) +} + +type failedTest struct { + Package string + Test string +} + +func (t *tester) findFailedTests(ctx context.Context) ([]failedTest, error) { + db, err := sql.Open("sqlite", t.Dir+dbPath) + if err != nil { + return nil, err + } + defer db.Close() + + rows, err := db.QueryContext(ctx, "SELECT DISTINCT Package, Test FROM test_results where Action='fail' and Test != ''") + if err != nil { + return nil, err + } + var out []failedTest + for rows.Next() { + var pkg, test string + if err := rows.Scan(&pkg, &test); err != nil { + return nil, err + } + out = append(out, failedTest{pkg, test}) + } + return out, nil +} + +func filterOutFlags(flags []string, exclude *regexp.Regexp) []string { + out := make([]string, 0, len(flags)) + for _, f := range flags { + if !exclude.MatchString(f) { + out = append(out, f) + } + } + return out +} + +// summarize returns a markdown string of the test results. +func (t *tester) summarize() (string, error) { + ctx := context.Background() + var out strings.Builder + + testFailures, err := t.findFailedTests(ctx) + if err != nil { + return "", err + } + + plural := "s" + if len(testFailures) == 1 { + plural = "" + } + out.WriteString(fmt.Sprintf("## %d Test Failure%s\n\n", len(testFailures), plural)) + + db, err := sql.Open("sqlite", t.Dir+dbPath) + if err != nil { + return "", err + } + defer db.Close() + + rows, err := db.QueryContext(ctx, `SELECT + tr_output.Package, + tr_output.Test, + GROUP_CONCAT(tr_output.Output, "") AS Outputs +FROM + test_results tr_fail +JOIN + test_results tr_output +ON + tr_fail.Test = tr_output.Test + AND tr_fail.BatchInsertTime = tr_output.BatchInsertTime + AND tr_fail.Package = tr_output.Package +WHERE + tr_fail.Action = 'fail' + AND tr_output.Test != '' +GROUP BY + tr_output.BatchInsertTime, + tr_output.Package, + tr_output.Test +ORDER BY + MIN(tr_output.Time);`) + if err != nil { + return "", err + } + for rows.Next() { + var pkg, test, outputs string + if err := rows.Scan(&pkg, &test, &outputs); err != nil { + return "", err + } + _, err = out.WriteString(fmt.Sprintf(`
+%s.%s +
+%s
+
+
`, pkg, test, outputs)) + if err != nil { + return "", err + } + } + return out.String(), nil +} diff --git a/scripts/test_analysis/main_test.go b/scripts/test_analysis/main_test.go new file mode 100644 index 0000000000..b8a5dae1be --- /dev/null +++ b/scripts/test_analysis/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os" + "testing" +) + +func TestFailsOnConsistentFailure(t *testing.T) { + tmpDir := t.TempDir() + "/" + os.WriteFile(tmpDir+"/main.go", []byte(`package main +func main() {}`), 0644) + // Add a test that fails consistently. + os.WriteFile(tmpDir+"/main_test.go", []byte(`package main + +import ( + "testing" +) +func TestConsistentFailure(t *testing.T) { + t.Fatal("consistent failure") +}`), 0644) + os.WriteFile(tmpDir+"/go.mod", []byte(`module example.com/test`), 0644) + + tstr := tester{Dir: tmpDir} + err := tstr.runTests(nil) + if err == nil { + t.Fatal("Should have failed with a consistent failure") + } +} + +func TestPassesOnFlakyFailure(t *testing.T) { + tmpDir := t.TempDir() + "/" + os.WriteFile(tmpDir+"/main.go", []byte(`package main +func main() { +}`), 0644) + // Add a test that fails the first time. + os.WriteFile(tmpDir+"/main_test.go", []byte(`package main +import ( + "os" + "testing" +) +func TestFlakyFailure(t *testing.T) { + _, err := os.Stat("foo") + if err != nil { + os.WriteFile("foo", []byte("hello"), 0644) + t.Fatal("flaky failure") + } +}`), 0644) + os.WriteFile(tmpDir+"/go.mod", []byte(`module example.com/test`), 0644) + + // Run the test. + tstr := tester{Dir: tmpDir} + err := tstr.runTests(nil) + if err != nil { + t.Fatal("Should have passed with a flaky test") + } +}