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.Size) (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, ports.MsgOutput)
33 msg, err := ports.UnmarshalOutput(out.Payload)
34 require.NoError(t, err)
35 require.Contains(t, string(msg.Data), "Rename session")
36 require.Contains(t, string(msg.Data), "> 0")
37 require.True(t, ac.overlays.promptActive())
38}
39
40func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
41 p, release := newBlockingPTY(t)
42 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
43 defer release()
44 sess.name = "0"
45 sess.ephemeral = true
46
47 d.enterPrompt(sess, ac, " Rename session ", sess.name, func(name string) error { return d.renameSession(sess, name) })
48 awaitFrame(t, sends, ports.MsgOutput)
49 d.handlePromptInput(ac, []byte("work\r"))
50 repaint := awaitFrame(t, sends, ports.MsgOutput)
51
52 require.False(t, ac.overlays.promptActive())
53 require.Equal(t, "0work", sess.name)
54 require.False(t, sess.ephemeral)
55 out, err := ports.UnmarshalOutput(repaint.Payload)
56 require.NoError(t, err)
57 require.Contains(t, string(out.Data), "0work")
58 require.NotContains(t, string(out.Data), "0work*")
59}
60
61func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
62 p1, release1 := newBlockingPTY(t)
63 p2, release2 := newBlockingPTY(t)
64 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
65 defer release1()
66 defer release2()
67 ctx, cancel := context.WithCancel(context.Background())
68 defer cancel()
69 d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
70
71 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
72 awaitFrame(t, sends, ports.MsgOutput)
73 d.handlePromptInput(ac, []byte("taken\r"))
74 repaint := awaitFrame(t, sends, ports.MsgOutput)
75
76 require.True(t, ac.overlays.promptActive())
77 require.NotEqual(t, "taken", sess.name)
78 out, err := ports.UnmarshalOutput(repaint.Payload)
79 require.NoError(t, err)
80 require.Contains(t, string(out.Data), "name already in use")
81}
82
83func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {
84 p, release := newBlockingPTY(t)
85 defer release()
86 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
87 cause := errors.New("fork/exec /bin/sh: permission denied")
88 d.ptys = failingPTYFactory{err: cause}
89
90 // Submit through the actual prompt input boundary rather than calling its
91 // callback directly.
92 d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
93 return d.createSessionAndSwitch(sess, ac, name)
94 })
95 awaitFrame(t, sends, ports.MsgOutput)
96 d.handlePromptInput(ac, []byte("new\r"))
97 repaint := awaitFrame(t, sends, ports.MsgOutput)
98 output, err := ports.UnmarshalOutput(repaint.Payload)
99 require.NoError(t, err)
100 require.Contains(t, string(output.Data), "couldn't create session: shell failed to start")
101
102 history := d.notices.history()
103 require.Len(t, history, 1, "the prompt boundary must report the failure once")
104 notice := history[0]
105 require.Equal(t, domain.NoticeSessionSpawn, notice.Code)
106 require.Equal(t, "couldn't create session: shell failed to start", notice.Message)
107 require.NotContains(t, notice.Message, cause.Error())
108 require.Contains(t, notice.Details, cause.Error())
109 require.True(t, ac.overlays.promptActive(), "failed submission stays inline")
110}
111
112func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) {
113 tests := []struct {
114 name string
115 input []byte
116 wantErr error
117 setup func(*Daemon)
118 }{
119 {name: "invalid name", input: []byte("invalid name\r"), wantErr: domain.ErrInvalidSessionName},
120 {name: "required name", input: []byte("\r"), wantErr: errSessionNameRequired},
121 {
122 name: "name in use",
123 input: []byte("taken\r"),
124 wantErr: errSessionNameInUse,
125 setup: func(d *Daemon) {
126 d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}}
127 },
128 },
129 }
130
131 for _, tt := range tests {
132 t.Run(tt.name, func(t *testing.T) {
133 p, release := newBlockingPTY(t)
134 defer release()
135 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
136 if tt.setup != nil {
137 tt.setup(d)
138 }
139
140 d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
141 return d.createSessionAndSwitch(sess, ac, name)
142 })
143 awaitFrame(t, sends, ports.MsgOutput)
144 d.handlePromptInput(ac, tt.input)
145 repaint := awaitFrame(t, sends, ports.MsgOutput)
146 output, err := ports.UnmarshalOutput(repaint.Payload)
147 require.NoError(t, err)
148 require.Contains(t, string(output.Data), tt.wantErr.Error())
149
150 require.True(t, ac.overlays.promptActive())
151 require.Empty(t, d.notices.history())
152 })
153 }
154}
155
156func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
157 p, release := newBlockingPTY(t)
158 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
159 defer release()
160 oldName := sess.name
161
162 d.enterPrompt(sess, ac, " Rename session ", oldName, func(name string) error { return d.renameSession(sess, name) })
163 awaitFrame(t, sends, ports.MsgOutput)
164 d.handlePromptInput(ac, []byte("new\x1b"))
165 awaitFrame(t, sends, ports.MsgOutput)
166
167 require.False(t, ac.overlays.promptActive())
168 require.Equal(t, oldName, sess.name)
169}
170
171func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
172 p, release := newBlockingPTY(t)
173 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
174 defer release()
175 sess.name = "0"
176 sess.ephemeral = true
177
178 d.enterPalette(sess, ac)
179 awaitFrame(t, sends, ports.MsgOutput)
180 d.handlePaletteInput(ac, []byte("RNS\r"))
181 out := awaitFrame(t, sends, ports.MsgOutput)
182
183 require.False(t, ac.overlays.paletteActive())
184 require.True(t, ac.overlays.promptActive())
185 msg, err := ports.UnmarshalOutput(out.Payload)
186 require.NoError(t, err)
187 require.Contains(t, string(msg.Data), "Rename session")
188 require.Contains(t, string(msg.Data), "> 0")
189}