1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-07-15 01:34:26 +02:00

Merge pull request #2113 from jesseduffield/better-test-structure

This commit is contained in:
Jesse Duffield
2022-08-14 17:31:53 +10:00
committed by GitHub
15 changed files with 709 additions and 443 deletions

View File

@ -98,7 +98,7 @@ jobs:
${{runner.os}}-go- ${{runner.os}}-go-
- name: Test code - name: Test code
run: | run: |
go test pkg/integration/*.go go test pkg/integration/clients/*.go
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@ -129,6 +129,12 @@ jobs:
- name: Build darwin binary - name: Build darwin binary
run: | run: |
GOOS=darwin go build GOOS=darwin go build
- name: Build integration test binary
run: |
GOOS=linux go build cmd/integration_test/main.go
- name: Build integration test injector
run: |
GOOS=linux go build pkg/integration/clients/injector/main.go
check-cheatsheet: check-cheatsheet:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:

View File

@ -0,0 +1,49 @@
package main
import (
"fmt"
"log"
"os"
"github.com/jesseduffield/lazygit/pkg/integration/clients"
)
var usage = `
Usage:
See https://github.com/jesseduffield/lazygit/tree/master/pkg/integration/README.md
CLI mode:
> go run cmd/integration_test/main.go cli <test1> <test2> ...
If you pass no test names, it runs all tests
Accepted environment variables:
KEY_PRESS_DELAY (e.g. 200): the number of milliseconds to wait between keypresses
MODE:
* ask (default): if a snapshot test fails, asks if you want to update the snapshot
* check: if a snapshot test fails, exits with an error
* update: if a snapshot test fails, updates the snapshot
* sandbox: uses the test's setup step to run the test in a sandbox where you can do whatever you want
TUI mode:
> go run cmd/integration_test/main.go tui
This will open up a terminal UI where you can run tests
Help:
> go run cmd/integration_test/main.go help
`
func main() {
if len(os.Args) < 2 {
log.Fatal(usage)
}
switch os.Args[1] {
case "help":
fmt.Println(usage)
case "cli":
clients.RunCLI(os.Args[2:])
case "tui":
clients.RunTUI()
default:
log.Fatal(usage)
}
}

View File

@ -20,7 +20,7 @@ import (
"github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/integration" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo" "github.com/samber/lo"
) )
@ -45,7 +45,7 @@ func CommandToRun() string {
} }
func GetDir() string { func GetDir() string {
return integration.GetRootDirectory() + "/docs/keybindings" return utils.GetLazygitRootDirectory() + "/docs/keybindings"
} }
func generateAtDir(cheatsheetDir string) { func generateAtDir(cheatsheetDir string) {

View File

@ -37,21 +37,21 @@ If you find yourself doing something frequently in a test, consider making it a
There are three ways to invoke a test: There are three ways to invoke a test:
1. go run pkg/integration/cmd/runner/main.go [<testname>...] 1. go run cmd/integration_test/main.go cli [<testname>...]
2. go run pkg/integration/cmd/tui/main.go 2. go run cmd/integration_test/main.go tui
3. go test pkg/integration/go_test.go 3. go test pkg/integration/clients/go_test.go
The first, the test runner, is for directly running a test from the command line. If you pass no arguments, it runs all tests. The first, the test runner, is for directly running a test from the command line. If you pass no arguments, it runs all tests.
The second, the TUI, is for running tests from a terminal UI where it's easier to find a test and run it without having to copy it's name and paste it into the terminal. This is the easiest approach by far. The second, the TUI, is for running tests from a terminal UI where it's easier to find a test and run it without having to copy it's name and paste it into the terminal. This is the easiest approach by far.
The third, the go-test command, intended only for use in CI, to be run along with the other `go test` tests. This runs the tests in headless mode so there's no visual output. The third, the go-test command, intended only for use in CI, to be run along with the other `go test` tests. This runs the tests in headless mode so there's no visual output.
The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is commit/new_branch. So to run it with our test runner you would run `go run pkg/integration/cmd/runner/main.go commit/new_branch`. The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is commit/new_branch. So to run it with our test runner you would run `go run cmd/integration_test/main.go cli commit/new_branch`.
You can pass the KEY_PRESS_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses, which helps for watching a test at a realistic speed to understand what it's doing. Or in the tui you can press 't' to run the test with a pre-set delay. You can pass the KEY_PRESS_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses, which helps for watching a test at a realistic speed to understand what it's doing. Or in the tui you can press 't' to run the test with a pre-set delay.
### Snapshots ### Snapshots
At the moment (this is subject to change) each test has a snapshot repo created after running for the first time. These snapshots live in `test/integration_new`, in folders named 'expected' (alongside the 'actual' folders which contain the resulting repo from the last test run). Whenever you run a test, the resultant repo will be compared against the snapshot repo and if they're different, you'll be asked whether you want to update the snapshot. If you want to update a snapshot without being prompted you can pass MODE=updateSnapshot to the test runner or the go test command. This is useful when you've made a change to At the moment (this is subject to change) each test has a snapshot repo created after running for the first time. These snapshots live in `test/integration_new`, in folders named 'expected' (alongside the 'actual' folders which contain the resulting repo from the last test run). Whenever you run a test, the resultant repo will be compared against the snapshot repo and if they're different, you'll be asked whether you want to update the snapshot. If you want to update a snapshot without being prompted you can pass MODE=update to the test runner.
### Sandbox mode ### Sandbox mode

View File

@ -0,0 +1,96 @@
package clients
import (
"log"
"os"
"os/exec"
"strconv"
"github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
)
// see pkg/integration/README.md
// The purpose of this program is to run integration tests. It does this by
// building our injector program (in the sibling injector directory) and then for
// each test we're running, invoke the injector program with the test's name as
// an environment variable. Then the injector finds the test and passes it to
// the lazygit startup code.
// If invoked directly, you can specify tests to run by passing their names as positional arguments
func RunCLI(testNames []string) {
err := components.RunTests(
getTestsToRun(testNames),
log.Printf,
runCmdInTerminal,
runAndPrintError,
getModeFromEnv(),
tryConvert(os.Getenv("KEY_PRESS_DELAY"), 0),
)
if err != nil {
log.Print(err.Error())
}
}
func runAndPrintError(test *components.IntegrationTest, f func() error) {
if err := f(); err != nil {
log.Print(err.Error())
}
}
func getTestsToRun(testNames []string) []*components.IntegrationTest {
var testsToRun []*components.IntegrationTest
if len(testNames) == 0 {
return tests.Tests
}
outer:
for _, testName := range testNames {
// check if our given test name actually exists
for _, test := range tests.Tests {
if test.Name() == testName {
testsToRun = append(testsToRun, test)
continue outer
}
}
log.Fatalf("test %s not found. Perhaps you forgot to add it to `pkg/integration/integration_tests/tests.go`?", testName)
}
return testsToRun
}
func runCmdInTerminal(cmd *exec.Cmd) error {
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
return cmd.Run()
}
func getModeFromEnv() components.Mode {
switch os.Getenv("MODE") {
case "", "ask":
return components.ASK_TO_UPDATE_SNAPSHOT
case "check":
return components.CHECK_SNAPSHOT
case "update":
return components.UPDATE_SNAPSHOT
case "sandbox":
return components.SANDBOX
default:
log.Fatalf("unknown test mode: %s, must be one of [ask, check, update, sandbox]", os.Getenv("MODE"))
panic("unreachable")
}
}
func tryConvert(numStr string, defaultVal int) int {
num, err := strconv.Atoi(numStr)
if err != nil {
return defaultVal
}
return num
}

View File

@ -1,7 +1,7 @@
//go:build !windows //go:build !windows
// +build !windows // +build !windows
package integration package clients
// this is the new way of running tests. See pkg/integration/integration_tests/commit.go // this is the new way of running tests. See pkg/integration/integration_tests/commit.go
// for an example // for an example
@ -11,11 +11,11 @@ import (
"io/ioutil" "io/ioutil"
"os" "os"
"os/exec" "os/exec"
"strconv"
"testing" "testing"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -24,14 +24,12 @@ func TestIntegration(t *testing.T) {
t.Skip("Skipping integration tests in short mode") t.Skip("Skipping integration tests in short mode")
} }
mode := GetModeFromEnv()
includeSkipped := os.Getenv("INCLUDE_SKIPPED") != ""
parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1) parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1)
parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0) parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0)
testNumber := 0 testNumber := 0
err := RunTests( err := components.RunTests(
tests.Tests,
t.Logf, t.Logf,
runCmdHeadless, runCmdHeadless,
func(test *components.IntegrationTest, f func() error) { func(test *components.IntegrationTest, f func() error) {
@ -45,8 +43,8 @@ func TestIntegration(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
}) })
}, },
mode, components.CHECK_SNAPSHOT,
includeSkipped, 0,
) )
assert.NoError(t, err) assert.NoError(t, err)
@ -68,12 +66,3 @@ func runCmdHeadless(cmd *exec.Cmd) error {
return f.Close() return f.Close()
} }
func tryConvert(numStr string, defaultVal int) int {
num, err := strconv.Atoi(numStr)
if err != nil {
return defaultVal
}
return num
}

View File

@ -6,17 +6,18 @@ import (
"github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/app/daemon"
"github.com/jesseduffield/lazygit/pkg/integration" "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types"
) )
// The purpose of this program is to run lazygit with an integration test passed in. // The purpose of this program is to run lazygit with an integration test passed in.
// We could have done the check on LAZYGIT_TEST_NAME in the root main.go but // We could have done the check on TEST_NAME in the root main.go but
// that would mean lazygit would be depending on integration test code which // that would mean lazygit would be depending on integration test code which
// would bloat the binary. // would bloat the binary.
// You should not invoke this program directly. Instead you should go through // You should not invoke this program directly. Instead you should go through
// pkg/integration/cmd/runner/main.go or pkg/integration/cmd/tui/main.go // go run cmd/integration_test/main.go
func main() { func main() {
dummyBuildInfo := &app.BuildInfo{ dummyBuildInfo := &app.BuildInfo{
@ -38,15 +39,20 @@ func getIntegrationTest() integrationTypes.IntegrationTest {
return nil return nil
} }
integrationTestName := os.Getenv(integration.LAZYGIT_TEST_NAME_ENV_VAR) if os.Getenv(components.SANDBOX_ENV_VAR) == "true" {
// when in sandbox mode we don't want the test controlling the gui
return nil
}
integrationTestName := os.Getenv(components.TEST_NAME_ENV_VAR)
if integrationTestName == "" { if integrationTestName == "" {
panic(fmt.Sprintf( panic(fmt.Sprintf(
"expected %s environment variable to be set, given that we're running an integration test", "expected %s environment variable to be set, given that we're running an integration test",
integration.LAZYGIT_TEST_NAME_ENV_VAR, components.TEST_NAME_ENV_VAR,
)) ))
} }
for _, candidateTest := range integration.Tests { for _, candidateTest := range tests.Tests {
if candidateTest.Name() == integrationTestName { if candidateTest.Name() == integrationTestName {
return candidateTest return candidateTest
} }

View File

@ -1,49 +1,29 @@
package main package clients
import ( import (
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings"
"github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/gocui" "github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui" "github.com/jesseduffield/lazygit/pkg/gui"
"github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/integration"
"github.com/jesseduffield/lazygit/pkg/integration/components" "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
"github.com/jesseduffield/lazygit/pkg/secureexec" "github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/utils"
) )
// This program lets you run integration tests from a TUI. See pkg/integration/README.md for more info. // This program lets you run integration tests from a TUI. See pkg/integration/README.md for more info.
type App struct { func RunTUI() {
tests []*components.IntegrationTest rootDir := utils.GetLazygitRootDirectory()
itemIdx int
testDir string
filtering bool
g *gocui.Gui
}
func (app *App) getCurrentTest() *components.IntegrationTest {
if len(app.tests) > 0 {
return app.tests[app.itemIdx]
}
return nil
}
func (app *App) loadTests() {
app.tests = integration.Tests
if app.itemIdx > len(app.tests)-1 {
app.itemIdx = len(app.tests) - 1
}
}
func main() {
rootDir := integration.GetRootDirectory()
testDir := filepath.Join(rootDir, "test", "integration") testDir := filepath.Join(rootDir, "test", "integration")
app := &App{testDir: testDir} app := newApp(testDir)
app.loadTests() app.loadTests()
g, err := gocui.NewGui(gocui.OutputTrue, false, gocui.NORMAL, false, gui.RuneReplacements) g, err := gocui.NewGui(gocui.OutputTrue, false, gocui.NORMAL, false, gui.RuneReplacements)
@ -71,6 +51,21 @@ func main() {
log.Panicln(err) log.Panicln(err)
} }
if err := g.SetKeybinding("list", gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
if app.itemIdx < len(app.filteredTests)-1 {
app.itemIdx++
}
listView, err := g.View("list")
if err != nil {
return err
}
listView.FocusPoint(0, app.itemIdx)
return nil
}); err != nil {
log.Panicln(err)
}
if err := g.SetKeybinding("list", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil { if err := g.SetKeybinding("list", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
log.Panicln(err) log.Panicln(err)
} }
@ -85,8 +80,7 @@ func main() {
return nil return nil
} }
cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true MODE=sandbox go run pkg/integration/cmd/runner/main.go %s", currentTest.Name())) suspendAndRunTest(currentTest, components.SANDBOX, 0)
app.runSubprocess(cmd)
return nil return nil
}); err != nil { }); err != nil {
@ -99,8 +93,7 @@ func main() {
return nil return nil
} }
cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true go run pkg/integration/cmd/runner/main.go %s", currentTest.Name())) suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, 0)
app.runSubprocess(cmd)
return nil return nil
}); err != nil { }); err != nil {
@ -113,8 +106,7 @@ func main() {
return nil return nil
} }
cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true KEY_PRESS_DELAY=200 go run pkg/integration/cmd/runner/main.go %s", currentTest.Name())) suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, 200)
app.runSubprocess(cmd)
return nil return nil
}); err != nil { }); err != nil {
@ -176,6 +168,26 @@ func main() {
return err return err
} }
app.filteredTests = tests.Tests
app.renderTests()
app.editorView.TextArea.Clear()
app.editorView.Clear()
app.editorView.Reset()
return nil
}); err != nil {
log.Panicln(err)
}
if err := g.SetKeybinding("editor", gocui.KeyEnter, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
app.filtering = false
if _, err := g.SetCurrentView("list"); err != nil {
return err
}
app.renderTests()
return nil return nil
}); err != nil { }); err != nil {
log.Panicln(err) log.Panicln(err)
@ -191,20 +203,74 @@ func main() {
} }
} }
func (app *App) runSubprocess(cmd *exec.Cmd) { type app struct {
filteredTests []*components.IntegrationTest
itemIdx int
testDir string
filtering bool
g *gocui.Gui
listView *gocui.View
editorView *gocui.View
}
func newApp(testDir string) *app {
return &app{testDir: testDir}
}
func (self *app) getCurrentTest() *components.IntegrationTest {
self.adjustCursor()
if len(self.filteredTests) > 0 {
return self.filteredTests[self.itemIdx]
}
return nil
}
func (self *app) loadTests() {
self.filteredTests = tests.Tests
self.adjustCursor()
}
func (self *app) adjustCursor() {
self.itemIdx = utils.Clamp(self.itemIdx, 0, len(self.filteredTests)-1)
}
func (self *app) filterWithString(needle string) {
if needle == "" {
self.filteredTests = tests.Tests
} else {
self.filteredTests = slices.Filter(tests.Tests, func(test *components.IntegrationTest) bool {
return strings.Contains(test.Name(), needle)
})
}
self.renderTests()
self.g.Update(func(g *gocui.Gui) error { return nil })
}
func (self *app) renderTests() {
self.listView.Clear()
for _, test := range self.filteredTests {
fmt.Fprintln(self.listView, test.Name())
}
}
func (self *app) wrapEditor(f func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool) func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
return func(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := f(v, key, ch, mod)
if matched {
self.filterWithString(v.TextArea.GetContent())
}
return matched
}
}
func suspendAndRunTest(test *components.IntegrationTest, mode components.Mode, keyPressDelay int) {
if err := gocui.Screen.Suspend(); err != nil { if err := gocui.Screen.Suspend(); err != nil {
panic(err) panic(err)
} }
cmd.Stdin = os.Stdin runTuiTest(test, mode, keyPressDelay)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
log.Println(err.Error())
}
cmd.Stdin = nil
cmd.Stderr = nil
cmd.Stdout = nil
fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint("press enter to return")) fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint("press enter to return"))
fmt.Scanln() // wait for enter press fmt.Scanln() // wait for enter press
@ -214,29 +280,31 @@ func (app *App) runSubprocess(cmd *exec.Cmd) {
} }
} }
func (app *App) layout(g *gocui.Gui) error { func (self *app) layout(g *gocui.Gui) error {
maxX, maxY := g.Size() maxX, maxY := g.Size()
descriptionViewHeight := 7 descriptionViewHeight := 7
keybindingsViewHeight := 3 keybindingsViewHeight := 3
editorViewHeight := 3 editorViewHeight := 3
if !app.filtering { if !self.filtering {
editorViewHeight = 0 editorViewHeight = 0
} else { } else {
descriptionViewHeight = 0 descriptionViewHeight = 0
keybindingsViewHeight = 0 keybindingsViewHeight = 0
} }
g.Cursor = app.filtering g.Cursor = self.filtering
g.FgColor = gocui.ColorGreen g.FgColor = gocui.ColorGreen
listView, err := g.SetView("list", 0, 0, maxX-1, maxY-descriptionViewHeight-keybindingsViewHeight-editorViewHeight-1, 0) listView, err := g.SetView("list", 0, 0, maxX-1, maxY-descriptionViewHeight-keybindingsViewHeight-editorViewHeight-1, 0)
if err != nil { if err != nil {
if err.Error() != "unknown view" { if err.Error() != "unknown view" {
return err return err
} }
listView.Highlight = true
listView.Clear() if self.listView == nil {
for _, test := range app.tests { self.listView = listView
fmt.Fprintln(listView, test.Name())
} }
listView.Highlight = true
self.renderTests()
listView.Title = "Tests" listView.Title = "Tests"
listView.FgColor = gocui.ColorDefault listView.FgColor = gocui.ColorDefault
if _, err := g.SetCurrentView("list"); err != nil { if _, err := g.SetCurrentView("list"); err != nil {
@ -270,12 +338,18 @@ func (app *App) layout(g *gocui.Gui) error {
if err.Error() != "unknown view" { if err.Error() != "unknown view" {
return err return err
} }
if self.editorView == nil {
self.editorView = editorView
}
editorView.Title = "Filter" editorView.Title = "Filter"
editorView.FgColor = gocui.ColorDefault editorView.FgColor = gocui.ColorDefault
editorView.Editable = true editorView.Editable = true
editorView.Editor = gocui.EditorFunc(self.wrapEditor(gocui.SimpleEditor))
} }
currentTest := app.getCurrentTest() currentTest := self.getCurrentTest()
if currentTest == nil { if currentTest == nil {
return nil return nil
} }
@ -283,24 +357,23 @@ func (app *App) layout(g *gocui.Gui) error {
descriptionView.Clear() descriptionView.Clear()
fmt.Fprint(descriptionView, currentTest.Description()) fmt.Fprint(descriptionView, currentTest.Description())
if err := g.SetKeybinding("list", gocui.KeyArrowDown, gocui.ModNone, func(*gocui.Gui, *gocui.View) error {
if app.itemIdx < len(app.tests)-1 {
app.itemIdx++
}
listView, err := g.View("list")
if err != nil {
return err
}
listView.FocusPoint(0, app.itemIdx)
return nil
}); err != nil {
log.Panicln(err)
}
return nil return nil
} }
func quit(g *gocui.Gui, v *gocui.View) error { func quit(g *gocui.Gui, v *gocui.View) error {
return gocui.ErrQuit return gocui.ErrQuit
} }
func runTuiTest(test *components.IntegrationTest, mode components.Mode, keyPressDelay int) {
err := components.RunTests(
[]*components.IntegrationTest{test},
log.Printf,
runCmdInTerminal,
runAndPrintError,
mode,
keyPressDelay,
)
if err != nil {
log.Println(err.Error())
}
}

View File

@ -1,73 +0,0 @@
package main
import (
"log"
"os"
"os/exec"
"github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/integration"
"github.com/jesseduffield/lazygit/pkg/integration/components"
)
// see pkg/integration/README.md
// The purpose of this program is to run integration tests. It does this by
// building our injector program (in the sibling injector directory) and then for
// each test we're running, invoke the injector program with the test's name as
// an environment variable. Then the injector finds the test and passes it to
// the lazygit startup code.
// If invoked directly, you can specify tests to run by passing their names as positional arguments
func main() {
mode := integration.GetModeFromEnv()
includeSkipped := os.Getenv("INCLUDE_SKIPPED") == "true"
var testsToRun []*components.IntegrationTest
if len(os.Args) > 1 {
outer:
for _, testName := range os.Args[1:] {
// check if our given test name actually exists
for _, test := range integration.Tests {
if test.Name() == testName {
testsToRun = append(testsToRun, test)
continue outer
}
}
log.Fatalf("test %s not found. Perhaps you forgot to add it to `pkg/integration/integration_tests/tests.go`?", testName)
}
} else {
testsToRun = integration.Tests
}
testNames := slices.Map(testsToRun, func(test *components.IntegrationTest) string {
return test.Name()
})
err := integration.RunTests(
log.Printf,
runCmdInTerminal,
func(test *components.IntegrationTest, f func() error) {
if !slices.Contains(testNames, test.Name()) {
return
}
if err := f(); err != nil {
log.Print(err.Error())
}
},
mode,
includeSkipped,
)
if err != nil {
log.Print(err.Error())
}
}
func runCmdInTerminal(cmd *exec.Cmd) error {
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
return cmd.Run()
}

View File

@ -0,0 +1,43 @@
package components
import "path/filepath"
// convenience struct for easily getting directories within our test directory.
// We have one test directory for each test, found in test/integration_new.
type Paths struct {
// e.g. test/integration/test_name
root string
}
func NewPaths(root string) Paths {
return Paths{root: root}
}
// when a test first runs, it's situated in a repo called 'repo' within this
// directory. In its setup step, the test is allowed to create other repos
// alongside the 'repo' repo in this directory, for example, creating remotes
// or repos to add as submodules.
func (self Paths) Actual() string {
return filepath.Join(self.root, "actual")
}
// this is the 'repo' directory within the 'actual' directory,
// where a lazygit test will start within.
func (self Paths) ActualRepo() string {
return filepath.Join(self.Actual(), "repo")
}
// When an integration test first runs, we copy everything in the 'actual' directory,
// and copy it into the 'expected' directory so that future runs can be compared
// against what we expect.
func (self Paths) Expected() string {
return filepath.Join(self.root, "expected")
}
func (self Paths) Config() string {
return filepath.Join(self.root, "used_config")
}
func (self Paths) Root() string {
return self.root
}

View File

@ -0,0 +1,216 @@
package components
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// this is the integration runner for the new and improved integration interface
const (
TEST_NAME_ENV_VAR = "TEST_NAME"
SANDBOX_ENV_VAR = "SANDBOX"
)
type Mode int
const (
// Default: if a snapshot test fails, the we'll be asked whether we want to update it
ASK_TO_UPDATE_SNAPSHOT Mode = iota
// fails the test if the snapshots don't match
CHECK_SNAPSHOT
// runs the test and updates the snapshot
UPDATE_SNAPSHOT
// This just makes use of the setup step of the test to get you into
// a lazygit session. Then you'll be able to do whatever you want. Useful
// when you want to test certain things without needing to manually set
// up the situation yourself.
// fails the test if the snapshots don't match
SANDBOX
)
func RunTests(
tests []*IntegrationTest,
logf func(format string, formatArgs ...interface{}),
runCmd func(cmd *exec.Cmd) error,
testWrapper func(test *IntegrationTest, f func() error),
mode Mode,
keyPressDelay int,
) error {
projectRootDir := utils.GetLazygitRootDirectory()
err := os.Chdir(projectRootDir)
if err != nil {
return err
}
testDir := filepath.Join(projectRootDir, "test", "integration_new")
if err := buildLazygit(); err != nil {
return err
}
for _, test := range tests {
test := test
paths := NewPaths(
filepath.Join(testDir, test.Name()),
)
testWrapper(test, func() error { //nolint: thelper
return runTest(test, paths, projectRootDir, logf, runCmd, mode, keyPressDelay)
})
}
return nil
}
func runTest(
test *IntegrationTest,
paths Paths,
projectRootDir string,
logf func(format string, formatArgs ...interface{}),
runCmd func(cmd *exec.Cmd) error,
mode Mode,
keyPressDelay int,
) error {
if test.Skip() {
logf("Skipping test %s", test.Name())
return nil
}
logf("path: %s", paths.Root())
if err := prepareTestDir(test, paths); err != nil {
return err
}
cmd, err := getLazygitCommand(test, paths, projectRootDir, mode, keyPressDelay)
if err != nil {
return err
}
err = runCmd(cmd)
if err != nil {
return err
}
return HandleSnapshots(paths, logf, test, mode)
}
func prepareTestDir(
test *IntegrationTest,
paths Paths,
) error {
findOrCreateDir(paths.Root())
deleteAndRecreateEmptyDir(paths.Actual())
err := os.Mkdir(paths.ActualRepo(), 0o777)
if err != nil {
return err
}
return createFixture(test, paths)
}
func buildLazygit() error {
osCommand := oscommands.NewDummyOSCommand()
return osCommand.Cmd.New(fmt.Sprintf(
"go build -o %s pkg/integration/clients/injector/main.go", tempLazygitPath(),
)).Run()
}
func createFixture(test *IntegrationTest, paths Paths) error {
originalDir, err := os.Getwd()
if err != nil {
return err
}
if err := os.Chdir(paths.ActualRepo()); err != nil {
panic(err)
}
shell := NewShell()
shell.RunCommand("git init")
shell.RunCommand(`git config user.email "CI@example.com"`)
shell.RunCommand(`git config user.name "CI"`)
test.SetupRepo(shell)
if err := os.Chdir(originalDir); err != nil {
panic(err)
}
return nil
}
func getLazygitCommand(test *IntegrationTest, paths Paths, rootDir string, mode Mode, keyPressDelay int) (*exec.Cmd, error) {
osCommand := oscommands.NewDummyOSCommand()
templateConfigDir := filepath.Join(rootDir, "test", "default_test_config")
err := os.RemoveAll(paths.Config())
if err != nil {
return nil, err
}
err = oscommands.CopyDir(templateConfigDir, paths.Config())
if err != nil {
return nil, err
}
cmdStr := fmt.Sprintf("%s -debug --use-config-dir=%s --path=%s %s", tempLazygitPath(), paths.Config(), paths.ActualRepo(), test.ExtraCmdArgs())
cmdObj := osCommand.Cmd.New(cmdStr)
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", TEST_NAME_ENV_VAR, test.Name()))
if mode == SANDBOX {
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", "SANDBOX", "true"))
}
if keyPressDelay > 0 {
cmdObj.AddEnvVars(fmt.Sprintf("KEY_PRESS_DELAY=%d", keyPressDelay))
}
return cmdObj.GetCmd(), nil
}
func tempLazygitPath() string {
return filepath.Join("/tmp", "lazygit", "test_lazygit")
}
func findOrCreateDir(path string) {
_, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
err = os.MkdirAll(path, 0o777)
if err != nil {
panic(err)
}
} else {
panic(err)
}
}
}
func deleteAndRecreateEmptyDir(path string) {
// remove contents of integration test directory
dir, err := ioutil.ReadDir(path)
if err != nil {
if os.IsNotExist(err) {
err = os.Mkdir(path, 0o777)
if err != nil {
panic(err)
}
} else {
panic(err)
}
}
for _, d := range dir {
os.RemoveAll(filepath.Join(path, d.Name()))
}
}

View File

@ -1,181 +1,131 @@
package integration package components
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/jesseduffield/generics/slices" "github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
// this is the integration runner for the new and improved integration interface // This creates and compares integration test snapshots.
var Tests = tests.Tests
type Mode int
const (
// Default: if a snapshot test fails, the we'll be asked whether we want to update it
ASK_TO_UPDATE_SNAPSHOT = iota
// fails the test if the snapshots don't match
CHECK_SNAPSHOT
// runs the test and updates the snapshot
UPDATE_SNAPSHOT
// This just makes use of the setup step of the test to get you into
// a lazygit session. Then you'll be able to do whatever you want. Useful
// when you want to test certain things without needing to manually set
// up the situation yourself.
// fails the test if the snapshots don't match
SANDBOX
)
const LAZYGIT_TEST_NAME_ENV_VAR = "LAZYGIT_TEST_NAME"
type ( type (
logf func(format string, formatArgs ...interface{}) logf func(format string, formatArgs ...interface{})
) )
func RunTests( func HandleSnapshots(paths Paths, logf logf, test *IntegrationTest, mode Mode) error {
return NewSnapshotter(paths, logf, test, mode).
handleSnapshots()
}
type Snapshotter struct {
paths Paths
logf logf
test *IntegrationTest
mode Mode
}
func NewSnapshotter(
paths Paths,
logf logf, logf logf,
runCmd func(cmd *exec.Cmd) error, test *IntegrationTest,
fnWrapper func(test *components.IntegrationTest, f func() error),
mode Mode, mode Mode,
includeSkipped bool, ) *Snapshotter {
) error { return &Snapshotter{
rootDir := GetRootDirectory() paths: paths,
err := os.Chdir(rootDir) logf: logf,
test: test,
mode: mode,
}
}
func (self *Snapshotter) handleSnapshots() error {
switch self.mode {
case UPDATE_SNAPSHOT:
return self.handleUpdate()
case CHECK_SNAPSHOT:
return self.handleCheck()
case ASK_TO_UPDATE_SNAPSHOT:
return self.handleAskToUpdate()
case SANDBOX:
self.logf("Sandbox session exited")
}
return nil
}
func (self *Snapshotter) handleUpdate() error {
if err := self.updateSnapshot(); err != nil {
return err
}
self.logf("Test passed: %s", self.test.Name())
return nil
}
func (self *Snapshotter) handleCheck() error {
self.logf("Comparing snapshots")
if err := self.compareSnapshots(); err != nil {
return err
}
self.logf("Test passed: %s", self.test.Name())
return nil
}
func (self *Snapshotter) handleAskToUpdate() error {
if _, err := os.Stat(self.paths.Expected()); os.IsNotExist(err) {
if err := self.updateSnapshot(); err != nil {
return err
}
self.logf("No existing snapshot found for %s. Created snapshot.", self.test.Name())
return nil
}
self.logf("Comparing snapshots...")
if err := self.compareSnapshots(); err != nil {
self.logf("%s", err)
// prompt user whether to update the snapshot (Y/N)
if promptUserToUpdateSnapshot() {
if err := self.updateSnapshot(); err != nil {
return err
}
self.logf("Snapshot updated: %s", self.test.Name())
} else {
return err
}
}
self.logf("Test passed: %s", self.test.Name())
return nil
}
func (self *Snapshotter) updateSnapshot() error {
// create/update snapshot
err := oscommands.CopyDir(self.paths.Actual(), self.paths.Expected())
if err != nil { if err != nil {
return err return err
} }
testDir := filepath.Join(rootDir, "test", "integration_new") if err := renameSpecialPaths(self.paths.Expected()); err != nil {
osCommand := oscommands.NewDummyOSCommand()
err = osCommand.Cmd.New(fmt.Sprintf("go build -o %s pkg/integration/cmd/injector/main.go", tempLazygitPath())).Run()
if err != nil {
return err return err
} }
for _, test := range Tests {
test := test
fnWrapper(test, func() error { //nolint: thelper
if test.Skip() && !includeSkipped {
logf("skipping test: %s", test.Name())
return nil
}
testPath := filepath.Join(testDir, test.Name())
actualDir := filepath.Join(testPath, "actual")
expectedDir := filepath.Join(testPath, "expected")
actualRepoDir := filepath.Join(actualDir, "repo")
logf("path: %s", testPath)
findOrCreateDir(testPath)
prepareIntegrationTestDir(actualDir)
findOrCreateDir(actualRepoDir)
err := createFixture(test, actualRepoDir, rootDir)
if err != nil {
return err
}
configDir := filepath.Join(testPath, "used_config")
cmd, err := getLazygitCommand(test, testPath, rootDir)
if err != nil {
return err
}
err = runCmd(cmd)
if err != nil {
return err
}
switch mode {
case UPDATE_SNAPSHOT:
if err := updateSnapshot(actualDir, expectedDir); err != nil {
return err
}
logf("Test passed: %s", test.Name())
case CHECK_SNAPSHOT:
if err := compareSnapshots(logf, configDir, actualDir, expectedDir, test.Name()); err != nil {
return err
}
logf("Test passed: %s", test.Name())
case ASK_TO_UPDATE_SNAPSHOT:
if _, err := os.Stat(expectedDir); os.IsNotExist(err) {
if err := updateSnapshot(actualDir, expectedDir); err != nil {
return err
}
logf("No existing snapshot found for %s. Created snapshot.", test.Name())
return nil
}
if err := compareSnapshots(logf, configDir, actualDir, expectedDir, test.Name()); err != nil {
logf("%s", err)
// prompt user whether to update the snapshot (Y/N)
if promptUserToUpdateSnapshot() {
if err := updateSnapshot(actualDir, expectedDir); err != nil {
return err
}
logf("Snapshot updated: %s", test.Name())
} else {
return err
}
}
logf("Test passed: %s", test.Name())
case SANDBOX:
logf("Session exited")
}
return nil
})
}
return nil return nil
} }
func promptUserToUpdateSnapshot() bool { func (self *Snapshotter) compareSnapshots() error {
fmt.Println("Test failed. Update snapshot? (y/n)")
var input string
fmt.Scanln(&input)
return input == "y"
}
func updateSnapshot(actualDir string, expectedDir string) error {
// create/update snapshot
err := oscommands.CopyDir(actualDir, expectedDir)
if err != nil {
return err
}
if err := renameSpecialPaths(expectedDir); err != nil {
return err
}
return err
}
func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir string, testName string) error {
// there are a couple of reasons we're not generating the snapshot in expectedDir directly: // there are a couple of reasons we're not generating the snapshot in expectedDir directly:
// Firstly we don't want to have to revert our .git file back to .git_keep. // Firstly we don't want to have to revert our .git file back to .git_keep.
// Secondly, the act of calling git commands like 'git status' actually changes the index // Secondly, the act of calling git commands like 'git status' actually changes the index
// for some reason, and we don't want to leave your lazygit working tree dirty as a result. // for some reason, and we don't want to leave your lazygit working tree dirty as a result.
expectedDirCopy := filepath.Join(os.TempDir(), "expected_dir_test", testName) expectedDirCopy := filepath.Join(os.TempDir(), "expected_dir_test", self.test.Name())
err := oscommands.CopyDir(expectedDir, expectedDirCopy) err := oscommands.CopyDir(self.paths.Expected(), expectedDirCopy)
if err != nil { if err != nil {
return err return err
} }
@ -191,7 +141,7 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir
return err return err
} }
err = validateSameRepos(expectedDirCopy, actualDir) err = validateSameRepos(expectedDirCopy, self.paths.Actual())
if err != nil { if err != nil {
return err return err
} }
@ -208,7 +158,7 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir
} }
// get corresponding file name from actual dir // get corresponding file name from actual dir
actualRepoPath := filepath.Join(actualDir, f.Name()) actualRepoPath := filepath.Join(self.paths.Actual(), f.Name())
expectedRepoPath := filepath.Join(expectedDirCopy, f.Name()) expectedRepoPath := filepath.Join(expectedDirCopy, f.Name())
actualRepo, expectedRepo, err := generateSnapshots(actualRepoPath, expectedRepoPath) actualRepo, expectedRepo, err := generateSnapshots(actualRepoPath, expectedRepoPath)
@ -218,11 +168,11 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir
if expectedRepo != actualRepo { if expectedRepo != actualRepo {
// get the log file and print it // get the log file and print it
bytes, err := ioutil.ReadFile(filepath.Join(configDir, "development.log")) bytes, err := ioutil.ReadFile(filepath.Join(self.paths.Config(), "development.log"))
if err != nil { if err != nil {
return err return err
} }
logf("%s", string(bytes)) self.logf("%s", string(bytes))
return errors.New(getDiff(f.Name(), actualRepo, expectedRepo)) return errors.New(getDiff(f.Name(), actualRepo, expectedRepo))
} }
@ -231,95 +181,11 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir
return nil return nil
} }
func createFixture(test *components.IntegrationTest, actualDir string, rootDir string) error { func promptUserToUpdateSnapshot() bool {
if err := os.Chdir(actualDir); err != nil { fmt.Println("Test failed. Update snapshot? (y/n)")
panic(err) var input string
} fmt.Scanln(&input)
return input == "y"
shell := components.NewShell()
shell.RunCommand("git init")
shell.RunCommand(`git config user.email "CI@example.com"`)
shell.RunCommand(`git config user.name "CI"`)
test.SetupRepo(shell)
// changing directory back to rootDir after the setup is done
if err := os.Chdir(rootDir); err != nil {
panic(err)
}
return nil
}
func getLazygitCommand(test *components.IntegrationTest, testPath string, rootDir string) (*exec.Cmd, error) {
osCommand := oscommands.NewDummyOSCommand()
templateConfigDir := filepath.Join(rootDir, "test", "default_test_config")
actualRepoDir := filepath.Join(testPath, "actual", "repo")
configDir := filepath.Join(testPath, "used_config")
err := os.RemoveAll(configDir)
if err != nil {
return nil, err
}
err = oscommands.CopyDir(templateConfigDir, configDir)
if err != nil {
return nil, err
}
cmdStr := fmt.Sprintf("%s -debug --use-config-dir=%s --path=%s %s", tempLazygitPath(), configDir, actualRepoDir, test.ExtraCmdArgs())
cmdObj := osCommand.Cmd.New(cmdStr)
cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", LAZYGIT_TEST_NAME_ENV_VAR, test.Name()))
return cmdObj.GetCmd(), nil
}
func GetModeFromEnv() Mode {
switch os.Getenv("MODE") {
case "", "ask":
return ASK_TO_UPDATE_SNAPSHOT
case "check":
return CHECK_SNAPSHOT
case "updateSnapshot":
return UPDATE_SNAPSHOT
case "sandbox":
return SANDBOX
default:
log.Fatalf("unknown test mode: %s, must be one of [test, record, updateSnapshot, sandbox]", os.Getenv("MODE"))
panic("unreachable")
}
}
func GetRootDirectory() string {
path, err := os.Getwd()
if err != nil {
panic(err)
}
for {
_, err := os.Stat(filepath.Join(path, ".git"))
if err == nil {
return path
}
if !os.IsNotExist(err) {
panic(err)
}
path = filepath.Dir(path)
if path == "/" {
log.Fatal("must run in lazygit folder or child folder")
}
}
}
func tempLazygitPath() string {
return filepath.Join("/tmp", "lazygit", "test_lazygit")
} }
func generateSnapshots(actualDir string, expectedDir string) (string, string, error) { func generateSnapshots(actualDir string, expectedDir string) (string, string, error) {
@ -491,38 +357,6 @@ func getFileName(f os.FileInfo) string {
return f.Name() return f.Name()
} }
func findOrCreateDir(path string) {
_, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
err = os.MkdirAll(path, 0o777)
if err != nil {
panic(err)
}
} else {
panic(err)
}
}
}
func prepareIntegrationTestDir(actualDir string) {
// remove contents of integration test directory
dir, err := ioutil.ReadDir(actualDir)
if err != nil {
if os.IsNotExist(err) {
err = os.Mkdir(actualDir, 0o777)
if err != nil {
panic(err)
}
} else {
panic(err)
}
}
for _, d := range dir {
os.RemoveAll(filepath.Join(actualDir, d.Name()))
}
}
func getDiff(prefix string, expected string, actual string) string { func getDiff(prefix string, expected string, actual string) string {
mockT := &MockTestingT{} mockT := &MockTestingT{}
assert.Equal(mockT, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual)) assert.Equal(mockT, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual))

View File

@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
// Deprecated: This file is part of the old way of doing things. See pkg/integration/cmd/runner/main.go for the new way // Deprecated: This file is part of the old way of doing things.
// see https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md // see https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md
// This file can be invoked directly, but you might find it easier to go through // This file can be invoked directly, but you might find it easier to go through

View File

@ -135,3 +135,30 @@ func FilePath(skip int) string {
_, path, _, _ := runtime.Caller(skip) _, path, _, _ := runtime.Caller(skip)
return path return path
} }
// for our cheatsheet script and integration tests. Not to be confused with finding the
// root directory of _any_ random repo.
func GetLazygitRootDirectory() string {
path, err := os.Getwd()
if err != nil {
panic(err)
}
for {
_, err := os.Stat(filepath.Join(path, ".git"))
if err == nil {
return path
}
if !os.IsNotExist(err) {
panic(err)
}
path = filepath.Dir(path)
if path == "/" {
log.Fatal("must run in lazygit folder or child folder")
}
}
}

View File

@ -24,10 +24,10 @@ func (f EditorFunc) Edit(v *View, key Key, ch rune, mod Modifier) bool {
} }
// DefaultEditor is the default editor. // DefaultEditor is the default editor.
var DefaultEditor Editor = EditorFunc(simpleEditor) var DefaultEditor Editor = EditorFunc(SimpleEditor)
// simpleEditor is used as the default gocui editor. // SimpleEditor is used as the default gocui editor.
func simpleEditor(v *View, key Key, ch rune, mod Modifier) bool { func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool {
switch { switch {
case key == KeyBackspace || key == KeyBackspace2: case key == KeyBackspace || key == KeyBackspace2:
v.TextArea.BackSpaceChar() v.TextArea.BackSpaceChar()