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