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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -14,6 +15,7 @@ import ( | |||
| 14 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | + | "github.com/bnema/vev/pkg/vt" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | // --- test doubles ----------------------------------------------------------- | |
| @@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 216 | 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 | 262 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |
| 220 | 263 | p1, releasePTY1 := newBlockingPTY(t) | |
| 221 | 264 | 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) | ||