Skip to content
Open
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: 16 additions & 0 deletions .vscode/launch.json
Copy link
Member

Choose a reason for hiding this comment

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

I think this file was committed by accident? Let's leave launch config up to the individual developer. If you'd like to add something consider adding it to contrib/vscode/launch.json.sample.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "debug",
"args": ["--rerun-fails"],
"program": "${workspaceFolder}/main.go"
}
]
}
79 changes: 79 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -126,6 +127,8 @@ func setupFlags(name string) (*pflag.FlagSet, *options) {

flags.BoolVar(&opts.debug, "debug", false, "enabled debug logging")
flags.BoolVar(&opts.version, "version", false, "show version and exit")
flags.StringVar(&opts.failedFirstJSONFile, "failed-first", "",
"path to previous run's JSON file. Failed tests from this file will be run first")
return flags, opts
}

Expand Down Expand Up @@ -200,13 +203,19 @@ type options struct {
watchChdir bool
maxFails int
version bool
failedFirstJSONFile string

// shims for testing
stdout io.Writer
stderr io.Writer
}

func (o options) Validate() error {
if o.failedFirstJSONFile != "" {
if _, err := os.Stat(o.failedFirstJSONFile); os.IsNotExist(err) {
return fmt.Errorf("failed-first JSON file does not exist: %s", o.failedFirstJSONFile)
}
}
if o.rerunFailsMaxAttempts > 0 && len(o.args) > 0 && !o.rawCommand && len(o.packages) == 0 {
return fmt.Errorf(
"when go test args are used with --rerun-fails " +
Expand Down Expand Up @@ -270,6 +279,71 @@ func run(opts *options) error {
return err
}

// --failed-first logic
if opts.failedFirstJSONFile != "" {
data, err := os.ReadFile(opts.failedFirstJSONFile)
if err != nil {
return fmt.Errorf("failed to read previous run JSON file: %w", err)
}
// Parse JSON lines
var failedTests []struct{ Package, Test, Action string }
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var evt struct{ Package, Test, Action string }
_ = json.Unmarshal([]byte(line), &evt)
if evt.Action == "fail" && evt.Test != "" {
failedTests = append(failedTests, evt)
}
}
Comment on lines +289 to +299
Copy link
Member

Choose a reason for hiding this comment

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

There's code in the testjson package to do this: https://pkg.go.dev/gotest.tools/[email protected]/testjson#ScanTestOutput

I'd suggest using that to read the JSON.

// Run failed tests first
for _, tc := range failedTests {
goTestProc, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{
runFlag: goTestRunFlagForTestCase(testjson.TestName(tc.Test)),
pkg: tc.Package,
}))
if err != nil {
return err
}
cfg := testjson.ScanConfig{
Stdout: goTestProc.stdout,
Stderr: goTestProc.stderr,
Handler: &noopHandler{},
Stop: cancel,
}
_, _ = testjson.ScanTestOutput(cfg)
_ = goTestProc.cmd.Wait()
}
// Then run all tests
goTestProc, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{}))
if err != nil {
return err
}
handler, err := newEventHandler(opts)
if err != nil {
return err
}
defer handler.Close()
cfg := testjson.ScanConfig{
Stdout: goTestProc.stdout,
Stderr: goTestProc.stderr,
Handler: handler,
Stop: cancel,
IgnoreNonJSONOutputLines: opts.ignoreNonJSONOutputLines,
}
exec, err := testjson.ScanTestOutput(cfg)
handler.Flush()
if err != nil {
return finishRun(opts, exec, err)
}
exitErr := goTestProc.cmd.Wait()
if signum := atomic.LoadInt32(&goTestProc.signal); signum != 0 {
return finishRun(opts, exec, exitError{num: signalExitCode + int(signum)})
}
return finishRun(opts, exec, exitErr)
}

goTestProc, err := startGoTestFn(ctx, "", goTestCmdArgs(opts, rerunOpts{}))
if err != nil {
return err
Expand Down Expand Up @@ -541,3 +615,8 @@ func (w *cancelWaiter) Wait() error {
w.cancel()
return err
}

type noopHandler struct{}

func (s noopHandler) Event(testjson.TestEvent, *testjson.Execution) error { return nil }
func (s noopHandler) Err(string) error { return nil }
124 changes: 124 additions & 0 deletions cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,3 +550,127 @@ func TestRun_JsonFileTimingEvents(t *testing.T) {
assert.NilError(t, err)
golden.Assert(t, string(raw), "expected-jsonfile-timing-events")
}

func TestRun_FailedFirst_BasicOrder(t *testing.T) {
// Fake previous run JSON: TestFail1 and TestFail2 failed, TestPass passed
prevJSON := `{"Package": "pkg", "Test": "TestFail1", "Action": "fail"}
{"Package": "pkg", "Test": "TestFail2", "Action": "fail"}
{"Package": "pkg", "Test": "TestPass", "Action": "pass"}
{"Package": "pkg", "Action": "fail"}`

tmp := t.TempDir()
jsonFile := filepath.Join(tmp, "prev.json")
assert.NilError(t, os.WriteFile(jsonFile, []byte(prevJSON), 0644))

var called [][]string
fn := func(args []string) *proc {
called = append(called, args)
return &proc{
cmd: fakeWaiter{},
stdout: strings.NewReader("{}"),
stderr: bytes.NewReader(nil),
}
}
reset := patchStartGoTestFn(fn)
defer reset()

opts := &options{
failedFirstJSONFile: jsonFile,
stdout: new(bytes.Buffer),
stderr: new(bytes.Buffer),
format: "testname",
args: []string{"./test.test"},
hideSummary: newHideSummaryValue(),
}
// Should run TestFail1, TestFail2, then all tests
err := run(opts)
assert.NilError(t, err)
assert.Assert(t, len(called) >= 3) // 2 failed + 1 all
assert.Assert(t, strings.Contains(strings.Join(called[0], " "), "-test.run=^TestFail1$"))
assert.Assert(t, strings.Contains(strings.Join(called[1], " "), "-test.run=^TestFail2$"))
assert.Assert(t, !strings.Contains(strings.Join(called[2], " "), "-test.run=^TestFail")) // all tests, no -test.run
}

func TestRun_FailedFirst_NoFailedTests(t *testing.T) {
// Only passed tests in previous run
prevJSON := `{"Package": "pkg", "Test": "TestPass", "Action": "pass"}
{"Package": "pkg", "Action": "pass"}`
tmp := t.TempDir()
jsonFile := filepath.Join(tmp, "prev.json")
assert.NilError(t, os.WriteFile(jsonFile, []byte(prevJSON), 0644))

var called [][]string
fn := func(args []string) *proc {
called = append(called, args)
return &proc{
cmd: fakeWaiter{},
stdout: strings.NewReader("{}"),
stderr: bytes.NewReader(nil),
}
}
reset := patchStartGoTestFn(fn)
defer reset()

opts := &options{
failedFirstJSONFile: jsonFile,
stdout: new(bytes.Buffer),
stderr: new(bytes.Buffer),
format: "testname",
args: []string{"./test.test"},
hideSummary: newHideSummaryValue(),
}
err := run(opts)
assert.NilError(t, err)
assert.Equal(t, len(called), 1) // Only all tests
assert.Assert(t, !strings.Contains(strings.Join(called[0], " "), "-test.run"))
}

func TestRun_FailedFirst_InvalidFile(t *testing.T) {
opts := &options{
failedFirstJSONFile: "not-exist.json",
stdout: new(bytes.Buffer),
stderr: new(bytes.Buffer),
format: "testname",
args: []string{"./test.test"},
hideSummary: newHideSummaryValue(),
}
err := run(opts)
assert.ErrorContains(t, err, "failed-first JSON file does not exist")
}

func TestRun_FailedFirst_OnlyFailed(t *testing.T) {
// Only failed tests in previous run
prevJSON := `{"Package": "pkg", "Test": "TestFail1", "Action": "fail"}
{"Package": "pkg", "Test": "TestFail2", "Action": "fail"}
{"Package": "pkg", "Action": "fail"}`
tmp := t.TempDir()
jsonFile := filepath.Join(tmp, "prev.json")
assert.NilError(t, os.WriteFile(jsonFile, []byte(prevJSON), 0644))

var called [][]string
fn := func(args []string) *proc {
called = append(called, args)
return &proc{
cmd: fakeWaiter{},
stdout: strings.NewReader("{}"),
stderr: bytes.NewReader(nil),
}
}
reset := patchStartGoTestFn(fn)
defer reset()

opts := &options{
failedFirstJSONFile: jsonFile,
stdout: new(bytes.Buffer),
stderr: new(bytes.Buffer),
format: "testname",
args: []string{"./test.test"},
hideSummary: newHideSummaryValue(),
}
err := run(opts)
assert.NilError(t, err)
assert.Equal(t, len(called), 3) // 2 failed + 1 all
assert.Assert(t, strings.Contains(strings.Join(called[0], " "), "-test.run=^TestFail1$"))
assert.Assert(t, strings.Contains(strings.Join(called[1], " "), "-test.run=^TestFail2$"))
assert.Assert(t, !strings.Contains(strings.Join(called[2], " "), "-test.run=^TestFail"))
}
10 changes: 0 additions & 10 deletions cmd/rerunfails_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,3 @@ func (e exitCodeError) ExitCode() int {
func newExitCode(msg string, code int) error {
return exitCodeError{error: fmt.Errorf("%v", msg), code: code}
}

type noopHandler struct{}

func (s noopHandler) Event(testjson.TestEvent, *testjson.Execution) error {
return nil
}

func (s noopHandler) Err(string) error {
return nil
}
1 change: 1 addition & 0 deletions cmd/testdata/gotestsum-help-text
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ See https://pkg.go.dev/gotest.tools/gotestsum#section-readme for detailed docume

Flags:
--debug enabled debug logging
--failed-first string path to previous run's JSON file. Failed tests from this file will be run first
-f, --format string print format of test input (default "pkgname")
--format-hide-empty-pkg do not print empty packages in compact formats
--format-icons string use different icons, see help for options
Expand Down