diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0b695e5b..b175b02f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: ${{runner.os}}-go- - name: Test code run: | - go test pkg/integration/*.go + go test pkg/integration/clients/*.go build: runs-on: ubuntu-latest env: @@ -129,6 +129,12 @@ jobs: - name: Build darwin binary run: | 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: runs-on: ubuntu-latest env: diff --git a/cmd/integration_test/main.go b/cmd/integration_test/main.go new file mode 100644 index 000000000..492e5e19f --- /dev/null +++ b/cmd/integration_test/main.go @@ -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 ... + 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) + } +} diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index ec5c6db0e..3b9d9c2d1 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -20,7 +20,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/keybindings" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" - "github.com/jesseduffield/lazygit/pkg/integration" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -45,7 +45,7 @@ func CommandToRun() string { } func GetDir() string { - return integration.GetRootDirectory() + "/docs/keybindings" + return utils.GetLazygitRootDirectory() + "/docs/keybindings" } func generateAtDir(cheatsheetDir string) { diff --git a/pkg/integration/README.md b/pkg/integration/README.md index 01d5786b1..ba2365403 100644 --- a/pkg/integration/README.md +++ b/pkg/integration/README.md @@ -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: -1. go run pkg/integration/cmd/runner/main.go [...] -2. go run pkg/integration/cmd/tui/main.go -3. go test pkg/integration/go_test.go +1. go run cmd/integration_test/main.go cli [...] +2. go run cmd/integration_test/main.go tui +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 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 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. ### 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 diff --git a/pkg/integration/clients/cli.go b/pkg/integration/clients/cli.go new file mode 100644 index 000000000..76f0c9549 --- /dev/null +++ b/pkg/integration/clients/cli.go @@ -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 +} diff --git a/pkg/integration/go_test.go b/pkg/integration/clients/go_test.go similarity index 79% rename from pkg/integration/go_test.go rename to pkg/integration/clients/go_test.go index 0e55f9886..d52cd409a 100644 --- a/pkg/integration/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -1,7 +1,7 @@ //go:build !windows // +build !windows -package integration +package clients // this is the new way of running tests. See pkg/integration/integration_tests/commit.go // for an example @@ -11,11 +11,11 @@ import ( "io/ioutil" "os" "os/exec" - "strconv" "testing" "github.com/creack/pty" "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests" "github.com/stretchr/testify/assert" ) @@ -24,14 +24,12 @@ func TestIntegration(t *testing.T) { t.Skip("Skipping integration tests in short mode") } - mode := GetModeFromEnv() - includeSkipped := os.Getenv("INCLUDE_SKIPPED") != "" - parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1) parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0) testNumber := 0 - err := RunTests( + err := components.RunTests( + tests.Tests, t.Logf, runCmdHeadless, func(test *components.IntegrationTest, f func() error) { @@ -45,8 +43,8 @@ func TestIntegration(t *testing.T) { assert.NoError(t, err) }) }, - mode, - includeSkipped, + components.CHECK_SNAPSHOT, + 0, ) assert.NoError(t, err) @@ -68,12 +66,3 @@ func runCmdHeadless(cmd *exec.Cmd) error { return f.Close() } - -func tryConvert(numStr string, defaultVal int) int { - num, err := strconv.Atoi(numStr) - if err != nil { - return defaultVal - } - - return num -} diff --git a/pkg/integration/cmd/injector/main.go b/pkg/integration/clients/injector/main.go similarity index 71% rename from pkg/integration/cmd/injector/main.go rename to pkg/integration/clients/injector/main.go index 2f47d04a6..263dba5da 100644 --- a/pkg/integration/cmd/injector/main.go +++ b/pkg/integration/clients/injector/main.go @@ -6,17 +6,18 @@ import ( "github.com/jesseduffield/lazygit/pkg/app" "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" ) // 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 // would bloat the binary. // 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() { dummyBuildInfo := &app.BuildInfo{ @@ -38,15 +39,20 @@ func getIntegrationTest() integrationTypes.IntegrationTest { 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 == "" { panic(fmt.Sprintf( "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 { return candidateTest } diff --git a/pkg/integration/cmd/tui/main.go b/pkg/integration/clients/tui.go similarity index 64% rename from pkg/integration/cmd/tui/main.go rename to pkg/integration/clients/tui.go index c9e533f61..707e482ca 100644 --- a/pkg/integration/cmd/tui/main.go +++ b/pkg/integration/clients/tui.go @@ -1,49 +1,29 @@ -package main +package clients import ( "fmt" "log" "os" - "os/exec" "path/filepath" + "strings" + "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui" "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/tests" "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. -type App struct { - tests []*components.IntegrationTest - 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() +func RunTUI() { + rootDir := utils.GetLazygitRootDirectory() testDir := filepath.Join(rootDir, "test", "integration") - app := &App{testDir: testDir} + app := newApp(testDir) app.loadTests() g, err := gocui.NewGui(gocui.OutputTrue, false, gocui.NORMAL, false, gui.RuneReplacements) @@ -71,6 +51,21 @@ func main() { 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 { log.Panicln(err) } @@ -85,8 +80,7 @@ func main() { 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())) - app.runSubprocess(cmd) + suspendAndRunTest(currentTest, components.SANDBOX, 0) return nil }); err != nil { @@ -99,8 +93,7 @@ func main() { return nil } - cmd := secureexec.Command("sh", "-c", fmt.Sprintf("INCLUDE_SKIPPED=true go run pkg/integration/cmd/runner/main.go %s", currentTest.Name())) - app.runSubprocess(cmd) + suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, 0) return nil }); err != nil { @@ -113,8 +106,7 @@ func main() { 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())) - app.runSubprocess(cmd) + suspendAndRunTest(currentTest, components.ASK_TO_UPDATE_SNAPSHOT, 200) return nil }); err != nil { @@ -176,6 +168,26 @@ func main() { 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 }); err != nil { 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 { panic(err) } - cmd.Stdin = os.Stdin - 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 + runTuiTest(test, mode, keyPressDelay) fmt.Fprintf(os.Stdout, "\n%s", style.FgGreen.Sprint("press enter to return")) 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() descriptionViewHeight := 7 keybindingsViewHeight := 3 editorViewHeight := 3 - if !app.filtering { + if !self.filtering { editorViewHeight = 0 } else { descriptionViewHeight = 0 keybindingsViewHeight = 0 } - g.Cursor = app.filtering + g.Cursor = self.filtering g.FgColor = gocui.ColorGreen listView, err := g.SetView("list", 0, 0, maxX-1, maxY-descriptionViewHeight-keybindingsViewHeight-editorViewHeight-1, 0) if err != nil { if err.Error() != "unknown view" { return err } - listView.Highlight = true - listView.Clear() - for _, test := range app.tests { - fmt.Fprintln(listView, test.Name()) + + if self.listView == nil { + self.listView = listView } + + listView.Highlight = true + self.renderTests() listView.Title = "Tests" listView.FgColor = gocui.ColorDefault if _, err := g.SetCurrentView("list"); err != nil { @@ -270,12 +338,18 @@ func (app *App) layout(g *gocui.Gui) error { if err.Error() != "unknown view" { return err } + + if self.editorView == nil { + self.editorView = editorView + } + editorView.Title = "Filter" editorView.FgColor = gocui.ColorDefault editorView.Editable = true + editorView.Editor = gocui.EditorFunc(self.wrapEditor(gocui.SimpleEditor)) } - currentTest := app.getCurrentTest() + currentTest := self.getCurrentTest() if currentTest == nil { return nil } @@ -283,24 +357,23 @@ func (app *App) layout(g *gocui.Gui) error { descriptionView.Clear() 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 } func quit(g *gocui.Gui, v *gocui.View) error { 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()) + } +} diff --git a/pkg/integration/cmd/runner/main.go b/pkg/integration/cmd/runner/main.go deleted file mode 100644 index 1fbde96c5..000000000 --- a/pkg/integration/cmd/runner/main.go +++ /dev/null @@ -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() -} diff --git a/pkg/integration/components/paths.go b/pkg/integration/components/paths.go new file mode 100644 index 000000000..d01b58437 --- /dev/null +++ b/pkg/integration/components/paths.go @@ -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 +} diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go new file mode 100644 index 000000000..5a5022c53 --- /dev/null +++ b/pkg/integration/components/runner.go @@ -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())) + } +} diff --git a/pkg/integration/integration.go b/pkg/integration/components/snapshot.go similarity index 52% rename from pkg/integration/integration.go rename to pkg/integration/components/snapshot.go index ca71241e2..b7efa0fb0 100644 --- a/pkg/integration/integration.go +++ b/pkg/integration/components/snapshot.go @@ -1,181 +1,131 @@ -package integration +package components import ( "errors" "fmt" "io/ioutil" - "log" "os" - "os/exec" "path/filepath" "strings" "github.com/jesseduffield/generics/slices" "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" ) -// this is the integration runner for the new and improved integration interface - -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" +// This creates and compares integration test snapshots. type ( 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, - runCmd func(cmd *exec.Cmd) error, - fnWrapper func(test *components.IntegrationTest, f func() error), + test *IntegrationTest, mode Mode, - includeSkipped bool, -) error { - rootDir := GetRootDirectory() - err := os.Chdir(rootDir) +) *Snapshotter { + return &Snapshotter{ + paths: paths, + 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 { return err } - testDir := filepath.Join(rootDir, "test", "integration_new") - - osCommand := oscommands.NewDummyOSCommand() - err = osCommand.Cmd.New(fmt.Sprintf("go build -o %s pkg/integration/cmd/injector/main.go", tempLazygitPath())).Run() - if err != nil { + if err := renameSpecialPaths(self.paths.Expected()); err != nil { 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 } -func promptUserToUpdateSnapshot() bool { - 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 { +func (self *Snapshotter) compareSnapshots() error { // 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. // 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. - expectedDirCopy := filepath.Join(os.TempDir(), "expected_dir_test", testName) - err := oscommands.CopyDir(expectedDir, expectedDirCopy) + expectedDirCopy := filepath.Join(os.TempDir(), "expected_dir_test", self.test.Name()) + err := oscommands.CopyDir(self.paths.Expected(), expectedDirCopy) if err != nil { return err } @@ -191,7 +141,7 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir return err } - err = validateSameRepos(expectedDirCopy, actualDir) + err = validateSameRepos(expectedDirCopy, self.paths.Actual()) if err != nil { return err } @@ -208,7 +158,7 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir } // 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()) actualRepo, expectedRepo, err := generateSnapshots(actualRepoPath, expectedRepoPath) @@ -218,11 +168,11 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir if expectedRepo != actualRepo { // 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 { return err } - logf("%s", string(bytes)) + self.logf("%s", string(bytes)) return errors.New(getDiff(f.Name(), actualRepo, expectedRepo)) } @@ -231,95 +181,11 @@ func compareSnapshots(logf logf, configDir string, actualDir string, expectedDir return nil } -func createFixture(test *components.IntegrationTest, actualDir string, rootDir string) error { - if err := os.Chdir(actualDir); err != nil { - panic(err) - } - - 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 promptUserToUpdateSnapshot() bool { + fmt.Println("Test failed. Update snapshot? (y/n)") + var input string + fmt.Scanln(&input) + return input == "y" } func generateSnapshots(actualDir string, expectedDir string) (string, string, error) { @@ -491,38 +357,6 @@ func getFileName(f os.FileInfo) string { 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 { mockT := &MockTestingT{} assert.Equal(mockT, expected, actual, fmt.Sprintf("Unexpected %s. Expected:\n%s\nActual:\n%s\n", prefix, expected, actual)) diff --git a/pkg/integration/deprecated/cmd/runner/main.go b/pkg/integration/deprecated/cmd/runner/main.go index e225b3bd3..86f3c1f14 100644 --- a/pkg/integration/deprecated/cmd/runner/main.go +++ b/pkg/integration/deprecated/cmd/runner/main.go @@ -11,7 +11,7 @@ import ( "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 // This file can be invoked directly, but you might find it easier to go through diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 2f33862e8..9d6213c1d 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -135,3 +135,30 @@ func FilePath(skip int) string { _, path, _, _ := runtime.Caller(skip) 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") + } + } +} diff --git a/vendor/github.com/jesseduffield/gocui/edit.go b/vendor/github.com/jesseduffield/gocui/edit.go index 8c4b74adf..dde27e76a 100644 --- a/vendor/github.com/jesseduffield/gocui/edit.go +++ b/vendor/github.com/jesseduffield/gocui/edit.go @@ -24,10 +24,10 @@ func (f EditorFunc) Edit(v *View, key Key, ch rune, mod Modifier) bool { } // DefaultEditor is the default editor. -var DefaultEditor Editor = EditorFunc(simpleEditor) +var DefaultEditor Editor = EditorFunc(SimpleEditor) -// simpleEditor is used as the default gocui editor. -func simpleEditor(v *View, key Key, ch rune, mod Modifier) bool { +// SimpleEditor is used as the default gocui editor. +func SimpleEditor(v *View, key Key, ch rune, mod Modifier) bool { switch { case key == KeyBackspace || key == KeyBackspace2: v.TextArea.BackSpaceChar()