detached a3882e7

test(daemon): reproduce stale session switch frame

commit
a3882e7b55be51c37b774db2307f67dfc51c5af6
author
brice · · 2026-07-13 05:48 UTC
committer
brice · · 2026-07-13 05:49 UTC
parent
05e3a12
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +43 -0
M internal/usecase/daemon/picker_test.go +43 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67 "time"
78
@@ -14,6 +15,7 @@ import (
1415 "github.com/bnema/vev/internal/usecase/keys"
1516 "github.com/bnema/vev/internal/usecase/layout"
1617 "github.com/bnema/vev/internal/usecase/picker"
18+ "github.com/bnema/vev/pkg/vt"
1719 )
1820
1921 // --- test doubles -----------------------------------------------------------
@@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
216218 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
217219 }
218220
221+func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
222+ p1, releasePTY1 := newBlockingPTY(t)
223+ p2, releasePTY2 := newBlockingPTY(t)
224+ defer releasePTY1()
225+ defer releasePTY2()
226+ d, source, ac, sends := newManualSessionWithPTYs(t, p1)
227+ source.id, source.name = "source", "source"
228+ delete(d.sessions, domain.SessionID("manual"))
229+ d.sessions[source.id] = source
230+ target := &session{
231+ id: "target", name: "target", ctx: source.ctx, cancel: func() {},
232+ tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
233+ }
234+ d.sessions[target.id] = target
235+
236+ sourcePane := source.tabs[0].focusedPane()
237+ targetPane := target.tabs[0].focusedPane()
238+ require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
239+ require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
240+ sourcePane.screen.Write([]byte("SOURCE"))
241+ client := vt.NewScreen(80, 25)
242+ d.firstPaint(source, ac, ac.size)
243+ sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
244+ ac.ackOutputState(sourceOutput.NewStateNum)
245+
246+ targetPane.screen.Write([]byte("TARGET"))
247+ targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
248+ require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
249+ ac.previousSession.Set(target)
250+
251+ // Exercise the user-facing previous-session route, which delegates to the
252+ // real switchToTarget hand-off and immediately emits its required reset.
253+ d.backSession(source, ac)
254+ firstReset := awaitFrame(t, sends, ports.MsgOutput)
255+ out := mustApplyOutput(t, client, firstReset)
256+ require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair")
257+ frame := strings.Join(frameRows(client.Frame), "\n")
258+ require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
259+ require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
260+}
261+
219262 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
220263 p1, releasePTY1 := newBlockingPTY(t)
221264 p2, releasePTY2 := newBlockingPTY(t)
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -14,6 +15,7 @@ import (
14 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
15 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
16 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
18+ "github.com/bnema/vev/pkg/vt"
17 )19 )
18 20
19 // --- test doubles -----------------------------------------------------------21 // --- test doubles -----------------------------------------------------------
@@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
216 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)218 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
217 }219 }
218 220
221+func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
222+ p1, releasePTY1 := newBlockingPTY(t)
223+ p2, releasePTY2 := newBlockingPTY(t)
224+ defer releasePTY1()
225+ defer releasePTY2()
226+ d, source, ac, sends := newManualSessionWithPTYs(t, p1)
227+ source.id, source.name = "source", "source"
228+ delete(d.sessions, domain.SessionID("manual"))
229+ d.sessions[source.id] = source
230+ target := &session{
231+ id: "target", name: "target", ctx: source.ctx, cancel: func() {},
232+ tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
233+ }
234+ d.sessions[target.id] = target
235+
236+ sourcePane := source.tabs[0].focusedPane()
237+ targetPane := target.tabs[0].focusedPane()
238+ require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
239+ require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
240+ sourcePane.screen.Write([]byte("SOURCE"))
241+ client := vt.NewScreen(80, 25)
242+ d.firstPaint(source, ac, ac.size)
243+ sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
244+ ac.ackOutputState(sourceOutput.NewStateNum)
245+
246+ targetPane.screen.Write([]byte("TARGET"))
247+ targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
248+ require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
249+ ac.previousSession.Set(target)
250+
251+ // Exercise the user-facing previous-session route, which delegates to the
252+ // real switchToTarget hand-off and immediately emits its required reset.
253+ d.backSession(source, ac)
254+ firstReset := awaitFrame(t, sends, ports.MsgOutput)
255+ out := mustApplyOutput(t, client, firstReset)
256+ require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair")
257+ frame := strings.Join(frameRows(client.Frame), "\n")
258+ require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
259+ require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
260+}
261+
219 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {262 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
220 p1, releasePTY1 := newBlockingPTY(t)263 p1, releasePTY1 := newBlockingPTY(t)
221 p2, releasePTY2 := newBlockingPTY(t)264 p2, releasePTY2 := newBlockingPTY(t)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close