detached e6d3912

test(daemon): cover remote ownership recovery

commit
e6d3912950227eedeb1e9c5b8e8f8af51f32e630
author
brice · · 2026-08-07 05:54 UTC
parent
f1b7847
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +233 -0
A internal/usecase/daemon/remote_attachment_geometry_test.go +110 -0
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func attachRemoteGeometryClient(t *testing.T, view *remoteView, size domain.Size) (*attachedClient, chan ports.Frame) {
13+ t.Helper()
14+ transport, sends := newCapturingTransport(t)
15+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: size}
16+ ac.initOverlays()
17+ ac.setAttachmentOwner(view)
18+ view.mu.Lock()
19+ require.True(t, view.registerAttachmentLocked(ac))
20+ view.mu.Unlock()
21+ return ac, sends
22+}
23+
24+func resizeRemoteGeometryAttachment(t *testing.T, d *Daemon, view *remoteView, ac *attachedClient, size domain.Size, remoteSends chan ports.Frame) domain.Size {
25+ t.Helper()
26+ token := attachmentOwnerToken(view, ac, ac.transport())
27+ require.True(t, token.current())
28+ require.True(t, d.resizeAttachmentForLease(token, size))
29+
30+ frame := awaitFrame(t, remoteSends, ports.MsgResize)
31+ resize, err := ports.UnmarshalResize(frame.Payload)
32+ require.NoError(t, err)
33+ require.Equal(t, contentSize(size), resize.Size)
34+ return resize.Size
35+}
36+
37+func remoteViewScreenSize(t *testing.T, view *remoteView) domain.Size {
38+ t.Helper()
39+ view.mu.Lock()
40+ defer view.mu.Unlock()
41+ return domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height}
42+}
43+
44+func TestRemoteViewContentGeometryFollowsLatestValidAttachmentClaim(t *testing.T) {
45+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
46+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
47+ first, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24})
48+ second, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 120, Rows: 40})
49+
50+ // A smaller, later claim is authoritative even when a larger peer remains
51+ // attached; shared remote content follows claim order, not max dimensions.
52+ resizeRemoteGeometryAttachment(t, d, view, first, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent)
53+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, remoteViewScreenSize(t, view))
54+ resizeRemoteGeometryAttachment(t, d, view, second, domain.Size{Cols: 90, Rows: 30}, remoteTransport.sent)
55+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view))
56+
57+ // Invalid claims do not displace the most recent valid geometry.
58+ token := attachmentOwnerToken(view, first, first.transport())
59+ require.False(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 0, Rows: 0}))
60+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view))
61+}
62+
63+func TestNonAuthoritativeRemoteAttachmentRenderCannotResizeOrMutateSharedVT(t *testing.T) {
64+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
65+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
66+ nonAuthoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 60, Rows: 20})
67+ authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 110, Rows: 35})
68+ resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent)
69+
70+ view.mu.Lock()
71+ view.screen.Write([]byte("shared remote state"))
72+ before := screenLineText(view.screen, 0)
73+ view.mu.Unlock()
74+
75+ state, ok := d.captureRemoteViewRenderState(view, nonAuthoritative, renderCaptureRequest{})
76+ require.True(t, ok)
77+ require.Len(t, state.panes, 1)
78+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height})
79+
80+ // The composed snapshot is detached from the persistent remote VT too.
81+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X'})
82+ view.mu.Lock()
83+ after := screenLineText(view.screen, 0)
84+ size := domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height}
85+ view.mu.Unlock()
86+ require.Equal(t, before, after)
87+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, size)
88+}
89+
90+func TestRemoteResizeForwardingUsesAuthoritativeContentSize(t *testing.T) {
91+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
92+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
93+ authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24})
94+ peer, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 160, Rows: 50})
95+
96+ // The authoritative attachment's window is smaller than its peer's. The
97+ // forwarded remote resize must describe the authoritative content region,
98+ // exactly once past the local chrome boundary.
99+ want := domain.Size{Cols: 100, Rows: 28}
100+ got := resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 100, Rows: 30}, remoteTransport.sent)
101+ require.Equal(t, want, got)
102+ require.Equal(t, want, remoteViewScreenSize(t, view))
103+
104+ // Rendering the larger peer is attachment-local and cannot replace the
105+ // authoritative content size used by the remote link.
106+ state, ok := d.captureRemoteViewRenderState(view, peer, renderCaptureRequest{})
107+ require.True(t, ok)
108+ require.Equal(t, want, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height})
109+ require.Equal(t, want, remoteViewScreenSize(t, view))
110+}
@@ -0,0 +1,110 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func attachRemoteGeometryClient(t *testing.T, view *remoteView, size domain.Size) (*attachedClient, chan ports.Frame) {
13+ t.Helper()
14+ transport, sends := newCapturingTransport(t)
15+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: size}
16+ ac.initOverlays()
17+ ac.setAttachmentOwner(view)
18+ view.mu.Lock()
19+ require.True(t, view.registerAttachmentLocked(ac))
20+ view.mu.Unlock()
21+ return ac, sends
22+}
23+
24+func resizeRemoteGeometryAttachment(t *testing.T, d *Daemon, view *remoteView, ac *attachedClient, size domain.Size, remoteSends chan ports.Frame) domain.Size {
25+ t.Helper()
26+ token := attachmentOwnerToken(view, ac, ac.transport())
27+ require.True(t, token.current())
28+ require.True(t, d.resizeAttachmentForLease(token, size))
29+
30+ frame := awaitFrame(t, remoteSends, ports.MsgResize)
31+ resize, err := ports.UnmarshalResize(frame.Payload)
32+ require.NoError(t, err)
33+ require.Equal(t, contentSize(size), resize.Size)
34+ return resize.Size
35+}
36+
37+func remoteViewScreenSize(t *testing.T, view *remoteView) domain.Size {
38+ t.Helper()
39+ view.mu.Lock()
40+ defer view.mu.Unlock()
41+ return domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height}
42+}
43+
44+func TestRemoteViewContentGeometryFollowsLatestValidAttachmentClaim(t *testing.T) {
45+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
46+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
47+ first, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24})
48+ second, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 120, Rows: 40})
49+
50+ // A smaller, later claim is authoritative even when a larger peer remains
51+ // attached; shared remote content follows claim order, not max dimensions.
52+ resizeRemoteGeometryAttachment(t, d, view, first, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent)
53+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, remoteViewScreenSize(t, view))
54+ resizeRemoteGeometryAttachment(t, d, view, second, domain.Size{Cols: 90, Rows: 30}, remoteTransport.sent)
55+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view))
56+
57+ // Invalid claims do not displace the most recent valid geometry.
58+ token := attachmentOwnerToken(view, first, first.transport())
59+ require.False(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 0, Rows: 0}))
60+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, remoteViewScreenSize(t, view))
61+}
62+
63+func TestNonAuthoritativeRemoteAttachmentRenderCannotResizeOrMutateSharedVT(t *testing.T) {
64+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
65+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
66+ nonAuthoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 60, Rows: 20})
67+ authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 110, Rows: 35})
68+ resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 120, Rows: 40}, remoteTransport.sent)
69+
70+ view.mu.Lock()
71+ view.screen.Write([]byte("shared remote state"))
72+ before := screenLineText(view.screen, 0)
73+ view.mu.Unlock()
74+
75+ state, ok := d.captureRemoteViewRenderState(view, nonAuthoritative, renderCaptureRequest{})
76+ require.True(t, ok)
77+ require.Len(t, state.panes, 1)
78+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height})
79+
80+ // The composed snapshot is detached from the persistent remote VT too.
81+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X'})
82+ view.mu.Lock()
83+ after := screenLineText(view.screen, 0)
84+ size := domain.Size{Cols: view.screen.Frame.Width, Rows: view.screen.Frame.Height}
85+ view.mu.Unlock()
86+ require.Equal(t, before, after)
87+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, size)
88+}
89+
90+func TestRemoteResizeForwardingUsesAuthoritativeContentSize(t *testing.T) {
91+ d, view, _, remoteTransport := newRemoteMetadataLinkFixture(t)
92+ t.Cleanup(func() { require.NoError(t, remoteTransport.Close()) })
93+ authoritative, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 80, Rows: 24})
94+ peer, _ := attachRemoteGeometryClient(t, view, domain.Size{Cols: 160, Rows: 50})
95+
96+ // The authoritative attachment's window is smaller than its peer's. The
97+ // forwarded remote resize must describe the authoritative content region,
98+ // exactly once past the local chrome boundary.
99+ want := domain.Size{Cols: 100, Rows: 28}
100+ got := resizeRemoteGeometryAttachment(t, d, view, authoritative, domain.Size{Cols: 100, Rows: 30}, remoteTransport.sent)
101+ require.Equal(t, want, got)
102+ require.Equal(t, want, remoteViewScreenSize(t, view))
103+
104+ // Rendering the larger peer is attachment-local and cannot replace the
105+ // authoritative content size used by the remote link.
106+ state, ok := d.captureRemoteViewRenderState(view, peer, renderCaptureRequest{})
107+ require.True(t, ok)
108+ require.Equal(t, want, domain.Size{Cols: state.panes[0].frame.Width, Rows: state.panes[0].frame.Height})
109+ require.Equal(t, want, remoteViewScreenSize(t, view))
110+}
M internal/usecase/daemon/remote_link_test.go +56 -0
@@ -669,6 +669,62 @@ func TestRemoteLinkFailedAutoReconnectMarksViewUnavailableWithoutDetachingLocalO
669669 d.shutdownAll(ports.ReasonServerShutdown)
670670 }
671671
672+func TestStaleFailedReconnectCannotDegradeReplacementGeneration(t *testing.T) {
673+ d := newTestDaemon(t, nil, stubClock{})
674+ target := remoteLinkTestTarget()
675+ size := domain.Size{Cols: 80, Rows: 24}
676+ firstTransport := newRemoteLinkTestTransport()
677+ candidateTransport := newRemoteLinkTestTransport()
678+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
679+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
680+ &remoteLinkTestDialer{transport: firstTransport},
681+ &remoteLinkTestDialer{transport: candidateTransport},
682+ }}
683+ d.remoteDialerFactory = factory
684+
685+ view, err := d.openRemoteView(context.Background(), target, size)
686+ require.NoError(t, err)
687+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
688+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
689+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
690+ attachment.setAttachmentOwner(view)
691+ view.mu.Lock()
692+ require.True(t, view.registerAttachmentLocked(attachment))
693+ oldLink := view.link
694+ view.mu.Unlock()
695+
696+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
697+ awaitFrame(t, candidateTransport.sent, ports.MsgHello)
698+
699+ // A newer publication wins while the single automatic attempt is still
700+ // waiting for its candidate handshake. Its later failure must not turn the
701+ // replacement view unavailable or start a second retry.
702+ replacementTransport := newRemoteLinkTestTransport()
703+ view.mu.Lock()
704+ view.linkGeneration++
705+ replacement := &remoteLink{
706+ view: view, generation: view.linkGeneration, target: target, transport: replacementTransport,
707+ cancel: func() {}, done: make(chan struct{}), active: true,
708+ }
709+ view.link = replacement
710+ view.linkState = remoteViewLinkHealthy
711+ view.reconnectGeneration++
712+ view.mu.Unlock()
713+ candidateTransport.recv <- remoteLinkTestReceive{err: io.EOF}
714+ awaitTestCompletion(t, candidateTransport.closed, "stale reconnect candidate was not closed")
715+
716+ view.mu.Lock()
717+ require.Same(t, replacement, view.link)
718+ require.Equal(t, remoteViewLinkHealthy, view.linkState)
719+ view.mu.Unlock()
720+ require.Same(t, view, attachment.currentAttachmentOwner())
721+ require.Equal(t, int32(2), factory.calls.Load(), "a stale failed reconnect must not start another retry")
722+
723+ d.markRemoteLinkUnavailable(oldLink)
724+ require.Equal(t, int32(2), factory.calls.Load(), "a retired link cannot start another retry")
725+ d.shutdownAll(ports.ReasonServerShutdown)
726+}
727+
672728 func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) {
673729 d := newTestDaemon(t, nil, stubClock{})
674730 target := remoteLinkTestTarget()
@@ -669,6 +669,62 @@ func TestRemoteLinkFailedAutoReconnectMarksViewUnavailableWithoutDetachingLocalO
669 d.shutdownAll(ports.ReasonServerShutdown)669 d.shutdownAll(ports.ReasonServerShutdown)
670 }670 }
671 671
672+func TestStaleFailedReconnectCannotDegradeReplacementGeneration(t *testing.T) {
673+ d := newTestDaemon(t, nil, stubClock{})
674+ target := remoteLinkTestTarget()
675+ size := domain.Size{Cols: 80, Rows: 24}
676+ firstTransport := newRemoteLinkTestTransport()
677+ candidateTransport := newRemoteLinkTestTransport()
678+ enqueueRemoteLinkHandshake(t, firstTransport, target, size)
679+ factory := &remoteLinkSequenceFactory{dialers: []ports.Dialer{
680+ &remoteLinkTestDialer{transport: firstTransport},
681+ &remoteLinkTestDialer{transport: candidateTransport},
682+ }}
683+ d.remoteDialerFactory = factory
684+
685+ view, err := d.openRemoteView(context.Background(), target, size)
686+ require.NoError(t, err)
687+ awaitFrame(t, firstTransport.sent, ports.MsgHello)
688+ awaitFrame(t, firstTransport.sent, ports.MsgAck)
689+ attachment := &attachedClient{tr: &closeTrackingTransport{}}
690+ attachment.setAttachmentOwner(view)
691+ view.mu.Lock()
692+ require.True(t, view.registerAttachmentLocked(attachment))
693+ oldLink := view.link
694+ view.mu.Unlock()
695+
696+ firstTransport.recv <- remoteLinkTestReceive{err: io.EOF}
697+ awaitFrame(t, candidateTransport.sent, ports.MsgHello)
698+
699+ // A newer publication wins while the single automatic attempt is still
700+ // waiting for its candidate handshake. Its later failure must not turn the
701+ // replacement view unavailable or start a second retry.
702+ replacementTransport := newRemoteLinkTestTransport()
703+ view.mu.Lock()
704+ view.linkGeneration++
705+ replacement := &remoteLink{
706+ view: view, generation: view.linkGeneration, target: target, transport: replacementTransport,
707+ cancel: func() {}, done: make(chan struct{}), active: true,
708+ }
709+ view.link = replacement
710+ view.linkState = remoteViewLinkHealthy
711+ view.reconnectGeneration++
712+ view.mu.Unlock()
713+ candidateTransport.recv <- remoteLinkTestReceive{err: io.EOF}
714+ awaitTestCompletion(t, candidateTransport.closed, "stale reconnect candidate was not closed")
715+
716+ view.mu.Lock()
717+ require.Same(t, replacement, view.link)
718+ require.Equal(t, remoteViewLinkHealthy, view.linkState)
719+ view.mu.Unlock()
720+ require.Same(t, view, attachment.currentAttachmentOwner())
721+ require.Equal(t, int32(2), factory.calls.Load(), "a stale failed reconnect must not start another retry")
722+
723+ d.markRemoteLinkUnavailable(oldLink)
724+ require.Equal(t, int32(2), factory.calls.Load(), "a retired link cannot start another retry")
725+ d.shutdownAll(ports.ReasonServerShutdown)
726+}
727+
672 func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) {728 func TestOpenRemoteViewFailedReconnectPreservesViewUntilValidatedPublication(t *testing.T) {
673 d := newTestDaemon(t, nil, stubClock{})729 d := newTestDaemon(t, nil, stubClock{})
674 target := remoteLinkTestTarget()730 target := remoteLinkTestTarget()
M internal/usecase/daemon/remote_view_tabs.go +3 -0
@@ -135,6 +135,9 @@ func remoteViewRelativeTabCommand(view *remoteView, link *remoteLink, generation
135135 return "previous-tab", nil
136136 }
137137
138+// sendRemoteViewCommand is intentionally narrower than the remote daemon's
139+// attached-command executor. It is not palette forwarding: stable remote
140+// metadata only supports the two no-argument relative tab operations.
138141 func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error {
139142 if d == nil || link == nil || link.commands == nil {
140143 return errRemoteViewUnavailable
@@ -135,6 +135,9 @@ func remoteViewRelativeTabCommand(view *remoteView, link *remoteLink, generation
135 return "previous-tab", nil135 return "previous-tab", nil
136 }136 }
137 137
138+// sendRemoteViewCommand is intentionally narrower than the remote daemon's
139+// attached-command executor. It is not palette forwarding: stable remote
140+// metadata only supports the two no-argument relative tab operations.
138 func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error {141 func (d *Daemon) sendRemoteViewCommand(ctx context.Context, link *remoteLink, generation uint64, slug string) error {
139 if d == nil || link == nil || link.commands == nil {142 if d == nil || link == nil || link.commands == nil {
140 return errRemoteViewUnavailable143 return errRemoteViewUnavailable
A internal/usecase/daemon/remote_view_tabs_test.go +64 -0
@@ -0,0 +1,64 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSendRemoteViewCommandAllowsOnlyRelativeTabCommands(t *testing.T) {
12+ for _, slug := range []string{"next-tab", "previous-tab"} {
13+ t.Run(slug, func(t *testing.T) {
14+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
15+ link.commands = NewCommandRequestTracker()
16+
17+ done := make(chan error, 1)
18+ go func() {
19+ done <- d.sendRemoteViewCommand(context.Background(), link, link.generation, slug)
20+ }()
21+
22+ frame := awaitFrame(t, transport.sent, ports.MsgCommand)
23+ request, err := ports.UnmarshalCommandRequest(frame.Payload)
24+ require.NoError(t, err)
25+ require.Equal(t, ports.ProtocolVersion, request.Version)
26+ require.NotZero(t, request.RequestID)
27+ require.True(t, request.Attached)
28+ require.False(t, request.Self)
29+ require.Equal(t, slug, request.Slug)
30+ require.Empty(t, request.Args)
31+ require.Empty(t, request.TargetSession)
32+ require.Empty(t, request.TargetTab)
33+ require.Empty(t, request.TargetPane)
34+
35+ resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: request.RequestID, OK: true})
36+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload}))
37+ require.NoError(t, awaitTestValue(t, done, "relative remote tab command did not complete"))
38+ require.Zero(t, link.commands.PendingCount())
39+ })
40+ }
41+}
42+
43+func TestSendRemoteViewCommandRejectsOperationsOutsideRelativeTabAllowlist(t *testing.T) {
44+ for _, slug := range []string{
45+ "new-session",
46+ "focus-pane-left",
47+ "resize-pane",
48+ "move-tab",
49+ } {
50+ t.Run(slug, func(t *testing.T) {
51+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
52+ link.commands = NewCommandRequestTracker()
53+
54+ err := d.sendRemoteViewCommand(context.Background(), link, link.generation, slug)
55+ require.ErrorContains(t, err, "is not allowed")
56+ require.Zero(t, link.commands.PendingCount())
57+ select {
58+ case frame := <-transport.sent:
59+ t.Fatalf("unallowlisted remote operation sent frame type %d", frame.Type)
60+ default:
61+ }
62+ })
63+ }
64+}
@@ -0,0 +1,64 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestSendRemoteViewCommandAllowsOnlyRelativeTabCommands(t *testing.T) {
12+ for _, slug := range []string{"next-tab", "previous-tab"} {
13+ t.Run(slug, func(t *testing.T) {
14+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
15+ link.commands = NewCommandRequestTracker()
16+
17+ done := make(chan error, 1)
18+ go func() {
19+ done <- d.sendRemoteViewCommand(context.Background(), link, link.generation, slug)
20+ }()
21+
22+ frame := awaitFrame(t, transport.sent, ports.MsgCommand)
23+ request, err := ports.UnmarshalCommandRequest(frame.Payload)
24+ require.NoError(t, err)
25+ require.Equal(t, ports.ProtocolVersion, request.Version)
26+ require.NotZero(t, request.RequestID)
27+ require.True(t, request.Attached)
28+ require.False(t, request.Self)
29+ require.Equal(t, slug, request.Slug)
30+ require.Empty(t, request.Args)
31+ require.Empty(t, request.TargetSession)
32+ require.Empty(t, request.TargetTab)
33+ require.Empty(t, request.TargetPane)
34+
35+ resultPayload := ports.MarshalCommandResult(ports.CommandResult{RequestID: request.RequestID, OK: true})
36+ require.NoError(t, d.handleRemoteLinkFrame(link, ports.Frame{Type: ports.MsgCommandResult, Payload: resultPayload}))
37+ require.NoError(t, awaitTestValue(t, done, "relative remote tab command did not complete"))
38+ require.Zero(t, link.commands.PendingCount())
39+ })
40+ }
41+}
42+
43+func TestSendRemoteViewCommandRejectsOperationsOutsideRelativeTabAllowlist(t *testing.T) {
44+ for _, slug := range []string{
45+ "new-session",
46+ "focus-pane-left",
47+ "resize-pane",
48+ "move-tab",
49+ } {
50+ t.Run(slug, func(t *testing.T) {
51+ d, _, link, transport := newRemoteMetadataLinkFixture(t)
52+ link.commands = NewCommandRequestTracker()
53+
54+ err := d.sendRemoteViewCommand(context.Background(), link, link.generation, slug)
55+ require.ErrorContains(t, err, "is not allowed")
56+ require.Zero(t, link.commands.PendingCount())
57+ select {
58+ case frame := <-transport.sent:
59+ t.Fatalf("unallowlisted remote operation sent frame type %d", frame.Type)
60+ default:
61+ }
62+ })
63+ }
64+}

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 2

Tags 13

↑↓ moveenter openesc close