Add a test that a background refresh keeps the scroll position

The one behaviour that made scrolling the selection into view opt-in in
the first place — a background refresh must not yank the view back to a
selection the user scrolled away from — has never been covered by a test.
It's about to become the one case that the automatic scrolling has to
suppress, so cover it first.

Getting there needs two things from the test harness: mouse wheel events,
which are the only way to scroll a list panel without moving the
selection, and a way to trigger a background refresh. The periodic
routine that issues it is turned off in tests, and turning it on would
mean waiting for its timer and hoping it fires while we're looking, so
drive the refresh directly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-08-14 08:04:13 +02:00
co-authored by Claude Opus 5
parent a881fb7ee0
commit e9faf0325d
7 changed files with 89 additions and 0 deletions
+14
View File
@@ -69,6 +69,10 @@ func (self *GuiDriver) MouseMove(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
}
func (self *GuiDriver) ScrollWheelDown(x, y int) {
self.replayMouseEvent(x, y, tcell.WheelDown)
}
func (self *GuiDriver) MouseRelease(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
@@ -128,6 +132,16 @@ func (self *GuiDriver) FocusInAndClick(x, y int) {
self.waitTillIdle()
}
// RefreshInBackground performs the refresh that the background routines perform
// on a timer (see BackgroundRoutineMgr). Tests drive it directly rather than
// turning those routines on, so that they neither wait for a timer nor depend on
// one firing at a particular moment.
func (self *GuiDriver) RefreshInBackground() {
self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true})
self.waitTillIdle()
}
func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() {
self.gui.onUIThread(func() error {
self.gui.State.SetMergeOrRebaseStartedInLazygit(true)
+15
View File
@@ -78,6 +78,12 @@ func (self *TestDriver) repeatMouseMove() {
self.mouseMove(self.mouseX, self.mouseY)
}
func (self *TestDriver) scrollWheelDown(x, y int) {
self.SetCaption(fmt.Sprintf("Scrolling down at %d, %d", x, y))
self.gui.ScrollWheelDown(x, y)
self.Wait(self.inputDelay)
}
func (self *TestDriver) mouseRelease() {
self.SetCaption(fmt.Sprintf("Releasing mouse at %d, %d", self.mouseX, self.mouseY))
self.gui.MouseRelease(self.mouseX, self.mouseY)
@@ -136,6 +142,15 @@ func (self *TestDriver) Log(message string) {
self.gui.LogUI(message)
}
// RefreshInBackground performs the refresh that lazygit's background routines
// perform on a timer, e.g. to pick up changes made by RunCommand. Tests use this
// rather than turning those routines on and waiting for them.
func (self *TestDriver) RefreshInBackground() {
self.SetCaption("Refreshing in the background")
self.gui.RefreshInBackground()
self.Wait(self.inputDelay)
}
// allows the user to run shell commands during the test to emulate background activity
func (self *TestDriver) Shell() *Shell {
return self.shell
+6
View File
@@ -56,6 +56,12 @@ func (self *fakeGuiDriver) MouseRelease(x, y int) {
self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) ScrollWheelDown(x, y int) {
}
func (self *fakeGuiDriver) RefreshInBackground() {
}
func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) {
f()
}
@@ -544,6 +544,15 @@ func (self *ViewDriver) MouseMoveToBottom(x int) *ViewDriver {
return self.MouseMove(x, self.getView().InnerHeight()-1)
}
// scrolls the view down by one notch of the mouse wheel, i.e. by
// gui.scrollHeight lines. This moves the scroll position without moving the
// selection.
func (self *ViewDriver) ScrollWheelDown() *ViewDriver {
offsetX, offsetY, _, _ := self.getView().Dimensions()
self.t.scrollWheelDown(offsetX+1, offsetY+1)
return self
}
func (self *ViewDriver) RepeatMouseMove() *ViewDriver {
self.t.repeatMouseMove()
return self
+1
View File
@@ -496,6 +496,7 @@ var tests = []*components.IntegrationTest{
tag.Reset,
tag.ResetToDuplicateNamedBranch,
ui.Accordion,
ui.BackgroundRefreshKeepsScrollPosition,
ui.BranchesNotFirstTab,
ui.CommitsNotFirstTab,
ui.DisableSwitchTabWithPanelJumpKeys,
@@ -0,0 +1,41 @@
package ui
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BackgroundRefreshKeepsScrollPosition = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A background refresh doesn't scroll the selection back into view",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial commit")
for i := range 20 {
shell.CreateFile(fmt.Sprintf("file%02d", i), "")
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
SelectNextItem().
SelectedLine(Contains("file00")).
// Scroll the selection out of view with the mouse wheel
ScrollWheelDown().
ScrollWheelDown().
OriginY(4).
Tap(func() {
t.Shell().CreateFile("aaa", "")
t.RefreshInBackground()
}).
// The new file sorts before the selected one, so the selection has
// moved down a line; the view must stay where the user left it though
SelectedLineIdx(2).
OriginY(4)
},
})
+3
View File
@@ -31,6 +31,9 @@ type GuiDriver interface {
ClickAndHold(int, int)
MouseMove(int, int)
MouseRelease(int, int)
ScrollWheelDown(int, int)
// Perform the refresh that a background routine would perform on a timer
RefreshInBackground()
// Can be used to avoid data races with the UI thread in the uncommon cases that
// the test driver needs to assert state while the gui is not idle.
OnUIThreadAndWait(func())