1//go:build linux
2
3package app
4
5import (
6 "testing"
7 "time"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/daemon"
12 "github.com/stretchr/testify/require"
13)
14
15func sendAcceptanceInput(t *testing.T, tr ports.Transport, data string) {
16 t.Helper()
17 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})}))
18}
19
20func awaitAcceptanceFrame(t *testing.T, p *pump, typ ports.MsgType, predicate func(ports.Frame) bool) ports.Frame {
21 t.Helper()
22 deadline := time.NewTimer(5 * time.Second)
23 defer deadline.Stop()
24 for {
25 select {
26 case frame, ok := <-p.ch:
27 require.True(t, ok, "connection closed before frame")
28 if frame.Type == typ && predicate(frame) {
29 return frame
30 }
31 case <-deadline.C:
32 t.Fatal("timed out waiting for acceptance frame")
33 }
34 }
35}
36
37func awaitAcceptanceCommand(t *testing.T, p *pump, requestID uint64) ports.CommandResult {
38 t.Helper()
39 frame := awaitAcceptanceFrame(t, p, ports.MsgCommandResult, func(frame ports.Frame) bool {
40 result, err := ports.UnmarshalCommandResult(frame.Payload)
41 require.NoError(t, err)
42 return result.RequestID == requestID
43 })
44 result, err := ports.UnmarshalCommandResult(frame.Payload)
45 require.NoError(t, err)
46 return result
47}
48
49func awaitAcceptanceOutput(t *testing.T, p *pump, predicate func(ports.Output) bool) ports.Output {
50 t.Helper()
51 frame := awaitAcceptanceFrame(t, p, ports.MsgOutput, func(frame ports.Frame) bool {
52 output, err := ports.UnmarshalOutput(frame.Payload)
53 require.NoError(t, err)
54 return predicate(output)
55 })
56 output, err := ports.UnmarshalOutput(frame.Payload)
57 require.NoError(t, err)
58 return output
59}
60
61func awaitAcceptanceCommandResult(t *testing.T, tr ports.Transport, p *pump, requestID uint64, slug string) ports.CommandResult {
62 t.Helper()
63 payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
64 Version: ports.ProtocolVersion, RequestID: requestID, Attached: true, Slug: slug,
65 })
66 require.NoError(t, err)
67 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}))
68 return awaitAcceptanceCommand(t, p, requestID)
69}
70
71func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
72 size := domain.Size{Cols: 80, Rows: 24}
73 dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil))
74 first, firstPump := attach(t, dir, ports.IntentNew, "shared", size)
75 second, secondPump := attach(t, dir, ports.IntentAttach, "shared", size)
76 t.Cleanup(func() { _ = first.Close() })
77 t.Cleanup(func() { _ = second.Close() })
78
79 sendAcceptanceInput(t, first, "SHARED_INPUT\n")
80 awaitText(t, firstPump, size, "SHARED_INPUT")
81 awaitText(t, secondPump, size, "SHARED_INPUT")
82
83 // A tab opened by the first attachment is shared session topology, but its
84 // selected window is not. The second attachment stays on the original tab.
85 sendAcceptanceInput(t, first, "\x1b ")
86 awaitText(t, firstPump, size, "Commands")
87 sendAcceptanceInput(t, first, "CNT\r")
88 awaitText(t, firstPump, size, " 1 (cat) 2 (cat) ")
89 assertNoTextAfterInput(t, secondPump, size, " 1 (cat) 2 (cat) ")
90
91 sendAcceptanceInput(t, first, "\x1b ")
92 awaitText(t, firstPump, size, "Commands")
93 sendAcceptanceInput(t, first, "SPR\r")
94 sendAcceptanceInput(t, first, "FIRST_PANE_INPUT\n")
95 awaitText(t, firstPump, size, "FIRST_PANE_INPUT")
96 assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
97 sendAcceptanceInput(t, second, "SECOND_TAB_INPUT\n")
98 awaitText(t, secondPump, size, "SECOND_TAB_INPUT")
99
100 // Overlays are attachment-owned: a palette on one wire and copy mode on
101 // the other must not repaint the peer's viewport.
102 sendAcceptanceInput(t, first, "\x1b ")
103 awaitText(t, firstPump, size, "Commands")
104 assertNoTextAfterInput(t, secondPump, size, "Commands")
105 sendAcceptanceInput(t, first, "\x1b")
106
107 sendAcceptanceInput(t, second, "\x1b ")
108 awaitText(t, secondPump, size, "Commands")
109 sendAcceptanceInput(t, second, "VIS\r")
110 awaitText(t, secondPump, size, "[SCROLL]")
111 assertNoTextAfterInput(t, firstPump, size, "[SCROLL]")
112
113 // The resize keeps its attachment-local output stream while recalculating
114 // shared PTY/content geometry from the latest valid attachment claim; the
115 // peer stream remains independent.
116 resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})
117 require.NoError(t, err)
118 require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: resizePayload}))
119 resized := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Size == (domain.Size{Cols: 100, Rows: 30}) })
120 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, resized.Size)
121 assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
122
123 require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}))
124 reset := awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Base == 0 && output.Full })
125 require.True(t, reset.Full)
126 assertNoTextAfterInput(t, secondPump, size, "FIRST_PANE_INPUT")
127
128 require.NoError(t, first.Send(ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}))
129 deadline := time.NewTimer(5 * time.Second)
130 defer deadline.Stop()
131 for {
132 select {
133 case frame := <-firstPump.ch:
134 if frame.Type == ports.MsgDetached {
135 goto detached
136 }
137 case <-deadline.C:
138 t.Fatal("timed out waiting for first attachment detach")
139 }
140 }
141
142detached:
143 sendAcceptanceInput(t, second, "q")
144 sendAcceptanceInput(t, second, "PEER_SURVIVES\n")
145 awaitText(t, secondPump, size, "PEER_SURVIVES")
146}
147
148func TestAcceptanceAttachedCommandUsesItsConnectionOnly(t *testing.T) {
149 size := domain.Size{Cols: 80, Rows: 24}
150 dir, _ := startDaemon(t, daemon.WithShell("/bin/cat", nil))
151 first, firstPump := attach(t, dir, ports.IntentNew, "commands", size)
152 second, secondPump := attach(t, dir, ports.IntentAttach, "commands", size)
153 t.Cleanup(func() { _ = first.Close() })
154 t.Cleanup(func() { _ = second.Close() })
155
156 firstResult := awaitAcceptanceCommandResult(t, first, firstPump, 101, "new-tab")
157 secondResult := awaitAcceptanceCommandResult(t, second, secondPump, 202, "new-tab")
158 require.True(t, firstResult.OK, firstResult.Text)
159 require.True(t, secondResult.OK, secondResult.Text)
160 require.Empty(t, firstResult.Output)
161 require.Empty(t, secondResult.Output)
162 require.NotEqual(t, firstResult.RequestID, secondResult.RequestID)
163
164 // Drain any asynchronous output before proving that a reset on one real
165 // IPC transport does not create a reset frame on the other.
166 for {
167 select {
168 case <-secondPump.ch:
169 default:
170 goto drained
171 }
172 }
173
174drained:
175 require.NoError(t, first.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}))
176 awaitAcceptanceOutput(t, firstPump, func(output ports.Output) bool { return output.Full && output.Base == 0 })
177 assertNoTextAfterInput(t, secondPump, size, "Commands")
178}