jesseduffield.lazygit/pkg/integration/clients/go_test.go
Stefan Haller 8b049be31d Find the lazygit root directory by go.mod instead of .git
Running the tests in an exported source tarball fails with "must run in
lazy project folder or child folder". GetLazyRootDirectory searches the
working directory and its parents for a .git directory, and a tarball
doesn't have one. This has always affected the integration tests; since
34da956f5d a unit test calls the function too, so now even
`go test ./... -short` fails.

Search for the go.mod file that declares lazygit's module instead. It
ships in tarballs, and there is exactly one of it per source tree.

Put the function in our own pkg/utils rather than change lazycore's; the
criterion is specific to lazygit, and I don't feel like making a change
to lazycore.

Return an error rather than call log.Fatal, and report it from the two
callers that run under `go test`. In a test binary, log.Fatal exits
without attributing the failure to any test. That is the failure mode
34da956f5d set out to remove. The remaining callers are development
tools that have nothing useful to do without the root directory; they
keep exiting, now through MustFindLazygitRootDirectory.

Also stop the search at the root of the file system rather than at "/".
On Windows the old loop walks up to "C:\" and then spins there forever.
2026-09-09 09:27:44 +02:00

136 lines
4.3 KiB
Go

//go:build !windows
package clients
// This file allows you to use `go test` to run integration tests.
// See pkg/integration/README.md for more info.
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"syscall"
"testing"
"time"
"github.com/creack/pty"
"github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
func TestIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration tests in short mode")
}
parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1)
parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0)
raceDetector := os.Getenv("LAZYGIT_RACE_DETECTOR") != ""
logTimingsPath := os.Getenv("LAZYGIT_TEST_TIMING")
// LAZYGIT_GOCOVERDIR is the directory where we write coverage files to. If this directory
// is defined, go binaries built with the -cover flag will write coverage files to
// to it.
codeCoverageDir := os.Getenv("LAZYGIT_GOCOVERDIR")
testNumber := 0
rootDir, err := utils.FindLazygitRootDirectory()
if err != nil {
t.Fatal(err)
}
err = components.RunTests(components.RunTestArgs{
Tests: tests.GetTests(rootDir),
Logf: t.Logf,
RunCmd: runCmdHeadless,
TestWrapper: func(test *components.IntegrationTest, f func() error) {
defer func() { testNumber += 1 }()
if testNumber%parallelTotal != parallelIndex {
return
}
t.Run(test.Name(), func(t *testing.T) {
t.Parallel()
err := f()
assert.NoError(t, err)
})
},
Sandbox: false,
WaitForDebugger: false,
RaceDetector: raceDetector,
CodeCoverageDir: codeCoverageDir,
InputDelay: 0,
// Allow two attempts at each test to get around flakiness
MaxAttempts: 1,
LogTimingsPath: logTimingsPath,
})
assert.NoError(t, err)
}
func runCmdHeadless(cmd *exec.Cmd) (int, error) {
cmd.Env = append(
cmd.Env,
"LAZYGIT_HEADLESS=true",
"TERM=xterm",
)
// not writing stderr to the pty because we want to capture a panic if
// there is one. But some commands will not be in tty mode if stderr is
// not a terminal. We'll need to keep an eye out for that.
stderr := new(bytes.Buffer)
cmd.Stderr = stderr
// If lazygit exits but leaves behind a subprocess that inherited its stderr
// pipe, cmd.Wait blocks waiting for that pipe to reach EOF for as long as the
// subprocess stays alive. Unbounded, that hangs the whole test binary until
// its global timeout fires, and the timeout throws away whatever lazygit
// wrote to stderr before exiting (a panic, a -race report) -- the very output
// needed to diagnose the failure. WaitDelay caps the wait: once the process
// has exited, Wait gives the stderr goroutine at most this long to drain,
// then closes the pipe and returns ErrWaitDelay, so the captured stderr
// surfaces as the test error instead of being lost.
cmd.WaitDelay = 5 * time.Second
// these rows and columns are ignored because internally we use tcell's
// simulation screen. However we still need the pty for the sake of
// running other commands in a pty.
f, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 300, Cols: 300})
if err != nil {
return -1, err
}
// pty.StartWithSize starts lazygit in its own process group, so we can signal
// the whole group at once. Capture the id now, while the process is alive:
// once Wait has reaped it we can no longer look it up.
pgid, pgidErr := syscall.Getpgid(cmd.Process.Pid)
_, _ = io.Copy(io.Discard, f)
waitErr := cmd.Wait()
// On any failure -- including a WaitDelay expiry caused by a leaked
// subprocess -- kill the whole process group so a straggler can't linger and
// wedge a later test or pile up across a CI run. Best effort: usually the
// group is already gone (ESRCH), and a subprocess that called setsid to
// detach into its own group is out of reach, but WaitDelay still unblocks us.
if waitErr != nil && pgidErr == nil {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
}
if waitErr != nil {
_ = f.Close()
// Prefer lazygit's own stderr as the error; fall back to the wait error
// itself (e.g. ErrWaitDelay) when it exited without printing anything.
if stderr.Len() > 0 {
return cmd.Process.Pid, errors.New(stderr.String())
}
return cmd.Process.Pid, waitErr
}
return cmd.Process.Pid, f.Close()
}