1package daemon
2
3import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12)
13
14type failingPTYFactory struct{ err error }
15
16func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) {
17 return nil, f.err
18}
19
20func TestPromptModalGeometry(t *testing.T) {
21 base := domain.Size{Cols: 100, Rows: 40}
22
23 require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds)
24}
25
26func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
27 p, release := newBlockingPTY(t)
28 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
29 defer release()
30
31 d.enterPrompt(sess, ac, " Rename session ", "0", func(string) error { return nil })
32 out := awaitFrame(t, sends, "Output")
33 msg := unmarshalTestOutput(t, out.Payload)
34 require.Contains(t, string(msg.Data), "Rename session")
35 require.Contains(t, string(msg.Data), "> 0")
36 require.True(t, ac.overlays.promptActive())
37}
38
39func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
40 p, release := newBlockingPTY(t)
41 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
42 defer release()
43 sess.name = "0"
44 sess.ephemeral = true
45
46 d.enterPrompt(sess, ac, " Rename session ", sess.name, func(name string) error { return d.renameSession(sess, name) })
47 awaitFrame(t, sends, "Output")
48 d.handlePromptInput(ac, []byte("work\r"))
49 repaint := awaitFrame(t, sends, "Output")
50
51 require.False(t, ac.overlays.promptActive())
52 require.Equal(t, "0work", sess.name)
53 require.False(t, sess.ephemeral)
54 out := unmarshalTestOutput(t, repaint.Payload)
55 require.Contains(t, string(out.Data), "0work")
56 require.NotContains(t, string(out.Data), "0work*")
57}
58
59func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
60 p1, release1 := newBlockingPTY(t)
61 p2, release2 := newBlockingPTY(t)
62 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
63 defer release1()
64 defer release2()
65 ctx, cancel := context.WithCancel(context.Background())
66 defer cancel()
67 d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
68
69 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
70 awaitFrame(t, sends, "Output")
71 d.handlePromptInput(ac, []byte("taken\r"))
72 repaint := awaitFrame(t, sends, "Output")
73
74 require.True(t, ac.overlays.promptActive())
75 require.NotEqual(t, "taken", sess.name)
76 out := unmarshalTestOutput(t, repaint.Payload)
77 require.Contains(t, string(out.Data), "name already in use")
78}
79
80func TestTransitionPromptFailuresStayVisible(t *testing.T) {
81 tests := []struct {
82 name string
83 submit error
84 wantErr string
85 }{
86 {name: "destination unavailable", submit: errCreateDestinationUnavailable, wantErr: "that destination is no longer available"},
87 {name: "transition failed", submit: errors.New("transition failed"), wantErr: "transition failed"},
88 }
89 for _, tt := range tests {
90 t.Run(tt.name, func(t *testing.T) {
91 p, release := newBlockingPTY(t)
92 defer release()
93 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
94
95 d.enterTransitionPrompt(sess, ac, " Create session ", "", func(string, *attachmentEffect) error {
96 return tt.submit
97 })
98 awaitFrame(t, sends, "Output")
99 d.handlePromptInput(ac, []byte("example\r"))
100 repaint := awaitFrame(t, sends, "Output")
101
102 require.True(t, ac.overlays.promptActive())
103 out := unmarshalTestOutput(t, repaint.Payload)
104 require.Contains(t, string(out.Data), tt.wantErr)
105 })
106 }
107}
108
109func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {
110 p, release := newBlockingPTY(t)
111 defer release()
112 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
113 cause := errors.New("fork/exec /bin/sh: permission denied")
114 d.ptys = failingPTYFactory{err: cause}
115
116 // Submit through the actual prompt input boundary rather than calling its
117 // callback directly.
118 d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
119 return d.createSessionAndSwitch(sess, ac, name)
120 })
121 awaitFrame(t, sends, "Output")
122 d.handlePromptInput(ac, []byte("new\r"))
123 repaint := awaitFrame(t, sends, "Output")
124 output := unmarshalTestOutput(t, repaint.Payload)
125 require.Contains(t, string(output.Data), "couldn't create session: shell failed to start")
126
127 history := d.notices.history()
128 require.Len(t, history, 1, "the prompt boundary must report the failure once")
129 notice := history[0]
130 require.Equal(t, domain.NoticeSessionSpawn, notice.Code)
131 require.Equal(t, "couldn't create session: shell failed to start", notice.Message)
132 require.NotContains(t, notice.Message, cause.Error())
133 require.Contains(t, notice.Details, cause.Error())
134 require.True(t, ac.overlays.promptActive(), "failed submission stays inline")
135}
136
137func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) {
138 tests := []struct {
139 name string
140 input []byte
141 wantErr error
142 setup func(*Daemon)
143 }{
144 {name: "invalid name", input: []byte("invalid name\r"), wantErr: domain.ErrInvalidSessionName},
145 {name: "required name", input: []byte("\r"), wantErr: errSessionNameRequired},
146 {
147 name: "name in use",
148 input: []byte("taken\r"),
149 wantErr: errSessionNameInUse,
150 setup: func(d *Daemon) {
151 d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}}
152 },
153 },
154 }
155
156 for _, tt := range tests {
157 t.Run(tt.name, func(t *testing.T) {
158 p, release := newBlockingPTY(t)
159 defer release()
160 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
161 if tt.setup != nil {
162 tt.setup(d)
163 }
164
165 d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
166 return d.createSessionAndSwitch(sess, ac, name)
167 })
168 awaitFrame(t, sends, "Output")
169 d.handlePromptInput(ac, tt.input)
170 repaint := awaitFrame(t, sends, "Output")
171 output := unmarshalTestOutput(t, repaint.Payload)
172 require.Contains(t, string(output.Data), tt.wantErr.Error())
173
174 require.True(t, ac.overlays.promptActive())
175 require.Empty(t, d.notices.history())
176 })
177 }
178}
179
180func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
181 p, release := newBlockingPTY(t)
182 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
183 defer release()
184 oldName := sess.name
185
186 d.enterPrompt(sess, ac, " Rename session ", oldName, func(name string) error { return d.renameSession(sess, name) })
187 awaitFrame(t, sends, "Output")
188 d.handlePromptInput(ac, []byte("new\x1b"))
189 awaitFrame(t, sends, "Output")
190
191 require.False(t, ac.overlays.promptActive())
192 require.Equal(t, oldName, sess.name)
193}
194
195func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
196 p, release := newBlockingPTY(t)
197 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
198 defer release()
199 sess.name = "0"
200 sess.ephemeral = true
201
202 d.enterPalette(sess, ac)
203 awaitFrame(t, sends, "Output")
204 d.handlePaletteInput(ac, []byte("RNS\r"))
205 out := awaitFrame(t, sends, "Output")
206
207 require.False(t, ac.overlays.paletteActive())
208 require.True(t, ac.overlays.promptActive())
209 msg := unmarshalTestOutput(t, out.Payload)
210 require.Contains(t, string(msg.Data), "Rename session")
211 require.Contains(t, string(msg.Data), "> 0")
212}