attach_missing_prompt_test.go RawHistoryFolder

1//go:build linux
2
3package app
4
5// Attach-missing create prompt (Plan 003 E4).
6//
7// resolveAttachCreationIntent restores the `vev attach <missing>` create
8// prompt main once ran through the direct-dial preflight, but through the
9// broker: it connects over the same connectProductionClientBroker seam the
10// ordinary terminal composition uses, asks the destination daemon for its
11// session list (daemonReportsSessionMissing, starting it if needed), and on a
12// definite answer hands its already-open connection back so the run still
13// opens exactly one broker connection.
14
15import (
16	"context"
17	"errors"
18	"io"
19	"os"
20	"strings"
21	"syscall"
22	"testing"
23	"time"
24
25	"github.com/stretchr/testify/mock"
26	"github.com/stretchr/testify/require"
27
28	"github.com/bnema/vev/internal/adapters/term"
29	"github.com/bnema/vev/internal/ports"
30	portsmocks "github.com/bnema/vev/internal/ports/mocks"
31	"github.com/bnema/vev/internal/protocol"
32	"github.com/bnema/vev/internal/usecase/client"
33	"github.com/bnema/vev/pkg/rawterm"
34)
35
36// interactiveProbe injects the console decision, standing in for the
37// production probe so the prompt matrix runs without owning a real TTY.
38func interactiveProbe(interactive bool) func(ports.Terminal) bool {
39	return func(ports.Terminal) bool { return interactive }
40}
41
42// withAttachInteractiveConsole installs a scripted console decision for the
43// duration of a test.
44func withAttachInteractiveConsole(t *testing.T, interactive bool) {
45	t.Helper()
46	previous := attachInteractiveConsole
47	attachInteractiveConsole = interactiveProbe(interactive)
48	t.Cleanup(func() { attachInteractiveConsole = previous })
49}
50
51// withPreflightBroker installs a scripted connectProductionClientBroker seam
52// for the duration of a test, exactly like the terminal composition tests in
53// broker_client_test.go, so the preflight peek never reaches a production
54// broker.
55func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.BrokerService, error)) {
56	t.Helper()
57	previous := connectProductionClientBroker
58	connectProductionClientBroker = connect
59	t.Cleanup(func() { connectProductionClientBroker = previous })
60}
61
62// withDaemonSessions installs a scripted daemon listing: the destination named
63// by the route answers with sessions, or listErr when set. The broker
64// catalogue is deliberately not consulted, matching production.
65func withDaemonSessions(t *testing.T, sessions map[string][]string, listErr error) *[]client.BrokerOperationRoute {
66	t.Helper()
67	var routes []client.BrokerOperationRoute
68	previous := attachSessionLister
69	attachSessionLister = func(_ context.Context, _ ports.BrokerService, route client.BrokerOperationRoute) ([]protocol.SessionInfo, error) {
70		routes = append(routes, route)
71		if listErr != nil {
72			return nil, listErr
73		}
74		key := route.Destination.Registration.Endpoint
75		var out []protocol.SessionInfo
76		for _, name := range sessions[key] {
77			out = append(out, protocol.SessionInfo{Name: name})
78		}
79		return out, nil
80	}
81	t.Cleanup(func() { attachSessionLister = previous })
82	return &routes
83}
84
85func TestDaemonReportsSessionMissing(t *testing.T) {
86	remote := "user@example.test"
87	tests := []struct {
88		name        string
89		snapshot    ports.BrokerSnapshot
90		daemon      map[string][]string // listing per endpoint; "" is local
91		listErr     error
92		remote      string
93		target      string
94		wantMissing bool
95		wantListed  bool
96	}{
97		{name: "local session present", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "work", wantListed: true},
98		{name: "local session absent", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"work"}}, target: "gone", wantMissing: true, wantListed: true},
99		{name: "catalogue empty but daemon restored it", snapshot: terminalCompositionSnapshot(nil, nil), daemon: map[string][]string{"": {"vev"}}, target: "vev", wantListed: true},
100		{name: "daemon listing failed proves nothing", snapshot: terminalCompositionSnapshot(nil, nil), listErr: errors.New("unreachable"), target: "gone", wantListed: true},
101		{name: "remote session present", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "work", wantListed: true},
102		{name: "remote session absent", snapshot: terminalCompositionSnapshot(nil, map[string][]string{remote: nil}), daemon: map[string][]string{remote: {"work"}}, remote: remote, target: "gone", wantMissing: true, wantListed: true},
103		{name: "remote host not configured", snapshot: terminalCompositionSnapshot(nil, nil), remote: "user@elsewhere.test", target: "work"},
104	}
105	for _, tc := range tests {
106		t.Run(tc.name, func(t *testing.T) {
107			routes := withDaemonSessions(t, tc.daemon, tc.listErr)
108			service := portsmocks.NewMockBrokerService(t)
109			service.EXPECT().Snapshot().Return(tc.snapshot).Once()
110
111			require.Equal(t, tc.wantMissing, daemonReportsSessionMissing(context.Background(), service, tc.remote, tc.target))
112			if !tc.wantListed {
113				require.Empty(t, *routes, "an unconfigured host is never listed")
114				return
115			}
116			require.Len(t, *routes, 1)
117			route := (*routes)[0]
118			require.Equal(t, tc.snapshot.Epoch, route.Epoch)
119			require.Equal(t, tc.remote == "", route.Destination.Local)
120			if tc.remote != "" {
121				observation, _ := tc.snapshot.Find(tc.remote)
122				require.True(t, route.Destination.Registration.Equal(observation.Registration))
123			}
124		})
125	}
126}
127
128func TestResolveAttachCreationIntentSkipsNonAttach(t *testing.T) {
129	for _, tc := range []struct {
130		name   string
131		intent uint8
132		target string
133	}{
134		{name: "create", intent: protocol.IntentNew, target: "scratch"},
135		{name: "ephemeral", intent: protocol.IntentEphemeral, target: ""},
136		{name: "attach with no name", intent: protocol.IntentAttach, target: ""},
137	} {
138		t.Run(tc.name, func(t *testing.T) {
139			connected := false
140			withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
141				connected = true
142				return nil, errors.New("must not connect")
143			})
144			intent, service, err := resolveAttachCreationIntent(context.Background(), tc.intent, tc.target, "", nil)
145			require.NoError(t, err)
146			require.Equal(t, tc.intent, intent)
147			require.Nil(t, service)
148			require.False(t, connected, "a non-attach or nameless intent never peeks the broker")
149		})
150	}
151}
152
153func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) {
154	withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
155		return nil, errors.New("broker unreachable")
156	})
157	intent, service, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "scratch", "", nil)
158	require.NoError(t, err)
159	require.Equal(t, protocol.IntentAttach, intent)
160	require.Nil(t, service, "an undeterminable preflight must not hand back a connection")
161}
162
163func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) {
164	service := portsmocks.NewMockBrokerService(t)
165	service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
166	withDaemonSessions(t, map[string][]string{"": {"work"}}, nil)
167	withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
168	withAttachInteractiveConsole(t, true) // must not even be consulted
169
170	intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "", nil)
171	require.NoError(t, err)
172	require.Equal(t, protocol.IntentAttach, intent)
173	require.Same(t, service, got, "the peeked connection is handed back for reuse")
174}
175
176func TestResolveAttachCreationIntentUnknownHostAttachesWithoutPrompt(t *testing.T) {
177	service := portsmocks.NewMockBrokerService(t)
178	service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
179	withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
180	withAttachInteractiveConsole(t, true)
181
182	intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "user@elsewhere.test", nil)
183	require.NoError(t, err)
184	require.Equal(t, protocol.IntentAttach, intent)
185	require.Same(t, service, got)
186}
187
188func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
189	tests := []struct {
190		name           string
191		remote         string
192		answer         string
193		interactive    bool
194		wantIntent     uint8
195		wantPromptPart string
196		wantNoPrompt   bool
197	}{
198		{name: "local confirm creates", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "gone" doesn't exist, want to create and attach to it? [y/N]`},
199		{name: "local decline attaches", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "gone"},
200		{name: "local empty answer attaches", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
201		{name: "local unknown answer attaches", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"},
202		{name: "non-interactive console attaches without prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
203		{name: "remote confirm creates", remote: "user@example.test", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "gone" doesn't exist`},
204	}
205	for _, tc := range tests {
206		t.Run(tc.name, func(t *testing.T) {
207			var snapshot ports.BrokerSnapshot
208			if tc.remote == "" {
209				snapshot = terminalCompositionSnapshot([]string{"work"}, nil)
210			} else {
211				snapshot = terminalCompositionSnapshot(nil, map[string][]string{tc.remote: {"work"}})
212			}
213			service := portsmocks.NewMockBrokerService(t)
214			service.EXPECT().Snapshot().Return(snapshot).Once()
215			withDaemonSessions(t, map[string][]string{tc.remote: {"work"}}, nil)
216			withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
217			withAttachInteractiveConsole(t, tc.interactive)
218
219			terminal := portsmocks.NewMockTerminal(t)
220			var promptOut strings.Builder
221			terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
222			terminal.EXPECT().Out().Return(&promptOut).Maybe()
223			terminal.EXPECT().Flush().Return(nil).Maybe()
224
225			intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "gone", tc.remote, terminal)
226			require.NoError(t, err)
227			require.Equal(t, tc.wantIntent, intent)
228			require.Same(t, service, got, "the peeked connection is always handed back on a definite answer")
229			if tc.wantPromptPart != "" {
230				require.Contains(t, promptOut.String(), tc.wantPromptPart)
231			}
232			if tc.wantNoPrompt {
233				require.Empty(t, promptOut.String())
234			}
235		})
236	}
237}
238
239func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) {
240	service := portsmocks.NewMockBrokerService(t)
241	service.EXPECT().Snapshot().Return(terminalCompositionSnapshot(nil, nil)).Once()
242	service.EXPECT().Close().Return(nil).Once()
243	withDaemonSessions(t, nil, nil)
244	withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
245	withAttachInteractiveConsole(t, true)
246
247	terminal := portsmocks.NewMockTerminal(t)
248	reader, writer := io.Pipe()
249	t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() })
250	terminal.EXPECT().In().Return(reader).Maybe()
251	terminal.EXPECT().Out().Return(io.Discard).Maybe()
252	terminal.EXPECT().Flush().Return(nil).Maybe()
253
254	ctx, cancel := context.WithCancel(context.Background())
255	done := make(chan struct {
256		intent  uint8
257		service ports.BrokerService
258		err     error
259	}, 1)
260	go func() {
261		intent, got, err := resolveAttachCreationIntent(ctx, protocol.IntentAttach, "gone", "", terminal)
262		done <- struct {
263			intent  uint8
264			service ports.BrokerService
265			err     error
266		}{intent, got, err}
267	}()
268	cancel()
269	select {
270	case result := <-done:
271		require.ErrorIs(t, result.err, context.Canceled)
272		require.Nil(t, result.service, "a cancelled prompt closes and drops the peeked connection")
273	case <-time.After(5 * time.Second):
274		t.Fatal("resolveAttachCreationIntent did not release on cancellation")
275	}
276}
277
278func TestConfirmMissingSessionCreate(t *testing.T) {
279	tests := []struct {
280		name           string
281		answer         string
282		interactive    bool
283		wantIntent     uint8
284		wantPromptPart string
285		wantNoPrompt   bool
286	}{
287		{name: "accepts yes", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "scratch" doesn't exist, want to create and attach to it? [y/N]`},
288		{name: "accepts yes word", answer: "yes\n", interactive: true, wantIntent: protocol.IntentNew},
289		{name: "declines no", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach},
290		{name: "defaults no on empty answer", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
291		{name: "defaults no on unknown answer", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach},
292		{name: "non-interactive console skips the prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
293	}
294	for _, tc := range tests {
295		t.Run(tc.name, func(t *testing.T) {
296			withAttachInteractiveConsole(t, tc.interactive)
297			terminal := portsmocks.NewMockTerminal(t)
298			var out strings.Builder
299			terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
300			terminal.EXPECT().Out().Return(&out).Maybe()
301			terminal.EXPECT().Flush().Return(nil).Maybe()
302
303			intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
304			require.NoError(t, err)
305			require.Equal(t, tc.wantIntent, intent)
306			if tc.wantPromptPart != "" {
307				require.Contains(t, out.String(), tc.wantPromptPart)
308			}
309			if tc.wantNoPrompt {
310				require.Empty(t, out.String())
311			}
312		})
313	}
314}
315
316// TestConfirmMissingSessionCreateFailureAttaches covers a console that cannot
317// show the question: the prompt must keep the plain attach path instead of
318// consuming an answer nobody was asked for. The input blocks forever, so a
319// prompt that reads it would only return on context expiry.
320func TestConfirmMissingSessionCreateFailureAttaches(t *testing.T) {
321	closedOut, err := os.CreateTemp(t.TempDir(), "vev-console")
322	require.NoError(t, err)
323	require.NoError(t, closedOut.Close())
324
325	for _, tc := range []struct {
326		name     string
327		out      io.Writer
328		flushErr error
329	}{
330		{name: "write fails", out: closedOut},
331		{name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")},
332	} {
333		t.Run(tc.name, func(t *testing.T) {
334			withAttachInteractiveConsole(t, true)
335			consoleIn, consoleOut, err := os.Pipe()
336			require.NoError(t, err)
337			t.Cleanup(func() { _ = consoleIn.Close(); _ = consoleOut.Close() })
338
339			terminal := portsmocks.NewMockTerminal(t)
340			terminal.EXPECT().In().Return(consoleIn).Maybe()
341			terminal.EXPECT().Out().Return(tc.out).Maybe()
342			terminal.EXPECT().Flush().Return(tc.flushErr).Maybe()
343
344			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
345			defer cancel()
346			intent, err := confirmMissingSessionCreate(ctx, "scratch", terminal)
347			require.NoError(t, err, "an unusable console must not fail the attach")
348			require.Equal(t, protocol.IntentAttach, intent)
349		})
350	}
351}
352
353// TestConfirmMissingSessionCreateFlushesQuestion drives the real terminal
354// adapter, whose output is buffered: the prompt has to flush the question
355// before it blocks on the answer, or the user waits in front of a blank
356// console and the question only shows up after the client repaints.
357func TestConfirmMissingSessionCreateFlushesQuestion(t *testing.T) {
358	withAttachInteractiveConsole(t, true)
359	consoleIn, answers, err := os.Pipe()
360	require.NoError(t, err)
361	questions, consoleOut, err := os.Pipe()
362	require.NoError(t, err)
363	t.Cleanup(func() {
364		_ = consoleIn.Close()
365		_ = answers.Close()
366		_ = questions.Close()
367		_ = consoleOut.Close()
368	})
369
370	terminal := term.NewWithFiles(consoleIn, consoleOut)
371	done := make(chan struct {
372		intent uint8
373		err    error
374	}, 1)
375	go func() {
376		intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
377		done <- struct {
378			intent uint8
379			err    error
380		}{intent, err}
381	}()
382
383	question := readAttachPrompt(t, questions)
384	require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the prompt reads an answer")
385
386	_, err = answers.WriteString("y\n")
387	require.NoError(t, err)
388	select {
389	case result := <-done:
390		require.NoError(t, result.err)
391		require.Equal(t, protocol.IntentNew, result.intent)
392	case <-time.After(5 * time.Second):
393		t.Fatal("the prompt did not release after the answer")
394	}
395}
396
397// readAttachPrompt returns the console output that ends the create question,
398// failing the test instead of hanging when nothing arrives.
399func readAttachPrompt(t *testing.T, console *os.File) string {
400	t.Helper()
401	require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second)))
402	var output []byte
403	frame := make([]byte, 64)
404	for {
405		read, err := console.Read(frame)
406		output = append(output, frame[:read]...)
407		if strings.Contains(string(output), "[y/N] ") {
408			return string(output)
409		}
410		if err != nil {
411			t.Fatalf("prompt never reached the console (read %q: %v)", output, err)
412		}
413	}
414}
415
416// TestTerminalIsInteractive covers the production probe on both sides of the
417// console check: a real terminal file and the streams that are not consoles.
418func TestTerminalIsInteractive(t *testing.T) {
419	require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt")
420	pty := openAttachPtySlave(t)
421	require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console")
422
423	console, writer, err := os.Pipe()
424	require.NoError(t, err)
425	t.Cleanup(func() { _ = console.Close(); _ = writer.Close() })
426
427	for _, tc := range []struct {
428		name string
429		in   io.Reader
430	}{
431		{name: "detached reader", in: strings.NewReader("y\n")},
432		{name: "non-terminal file", in: console},
433	} {
434		t.Run(tc.name, func(t *testing.T) {
435			terminal := portsmocks.NewMockTerminal(t)
436			terminal.EXPECT().In().Return(tc.in).Maybe()
437			require.False(t, terminalIsInteractive(terminal))
438		})
439	}
440}
441
442// openAttachPtySlave returns the slave side of a real PTY pair: the only way
443// to hand the probe an *os.File the kernel reports as a terminal.
444func openAttachPtySlave(t *testing.T) *os.File {
445	t.Helper()
446	master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
447	if err != nil {
448		t.Skipf("open /dev/ptmx: %v", err)
449	}
450	t.Cleanup(func() { _ = master.Close() })
451	slave, err := rawterm.PreparePty(int(master.Fd()))
452	require.NoError(t, err, "prepare pty")
453	t.Cleanup(func() { _ = slave.Close() })
454	return slave
455}
456
457func TestPreconnectedBrokerConnectorReusesThenFallsBack(t *testing.T) {
458	first := portsmocks.NewMockBrokerService(t)
459	second := portsmocks.NewMockBrokerService(t)
460	fallback := portsmocks.NewMockBrokerConnector(t)
461	fallback.EXPECT().Connect(mock.Anything).Return(second, nil).Once()
462	connector := withPreconnected(fallback, first)
463
464	got, err := connector.Connect(context.Background())
465	require.NoError(t, err)
466	require.Same(t, first, got, "the first Connect reuses the preflight's own connection")
467
468	got, err = connector.Connect(context.Background())
469	require.NoError(t, err)
470	require.Same(t, second, got, "a later Connect reconnects through the fallback")
471}
472
473func TestWithPreconnectedNilPassesThroughUnchanged(t *testing.T) {
474	fallback := portsmocks.NewMockBrokerConnector(t)
475	fallback.EXPECT().Connect(mock.Anything).Return(nil, nil).Once()
476	got := withPreconnected(fallback, nil)
477	service, err := got.Connect(context.Background())
478	require.NoError(t, err)
479	require.Nil(t, service)
480}

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 12

Tags 11

↑↓ moveenter openesc close