1package daemon
2
3import (
4 "testing"
5 "time"
6
7 "github.com/stretchr/testify/require"
8
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/protocol/wire"
11)
12
13// awaitSentServerMessage drains captured frames until one satisfies match.
14func awaitSentServerMessage(t *testing.T, sends <-chan wire.Envelope, what string, match func(protocol.ServerMessage) bool) protocol.ServerMessage {
15 t.Helper()
16 deadline := time.After(2 * time.Second)
17 for {
18 select {
19 case frame := <-sends:
20 message := decodeServerMessage(t, frame)
21 if match(message) {
22 return message
23 }
24 case <-deadline:
25 t.Fatalf("daemon sent no %s", what)
26 return nil
27 }
28 }
29}
30
31// TestSessionPickerCommandOffersClientPickerOverLiveAttachment pins PA1 on the
32// daemon side: SSP keeps the attachment, sends a navigation offer the client
33// composes its own picker for, and opens no daemon interaction that could
34// swallow input.
35func TestSessionPickerCommandOffersClientPickerOverLiveAttachment(t *testing.T) {
36 d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
37 defer releaseAll(releases)
38
39 d.handleInput(source, ac, []byte("\x1b "))
40 require.True(t, ac.overlays.paletteActive())
41 d.handleInput(source, ac, []byte("SSP\r"))
42
43 offer := awaitSentServerMessage(t, sends, "navigation PickerOffer", func(message protocol.ServerMessage) bool {
44 _, ok := message.(protocol.PickerOffer)
45 return ok
46 }).(protocol.PickerOffer)
47 require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
48 require.NotZero(t, offer.InteractionID)
49 require.False(t, ac.overlays.pickerClientActive(), "a navigation offer must not open a daemon interaction")
50 require.Same(t, source, ac.currentAttachmentSession(), "SSP must keep the attachment live")
51 assertNoFurtherFrame(t, sends, "Detached", "SSP must not detach the attachment")
52}
53
54// TestPaletteClientPickerFollowsPaletteErasingFrame pins the ordering the
55// client overlay depends on: it suppresses attachment output once the picker
56// opens, so the frame erasing the palette must reach it before the offer (SSP)
57// or the first move snapshot (MFP), even while output is ACK-blocked.
58func TestPaletteClientPickerFollowsPaletteErasingFrame(t *testing.T) {
59 tests := []struct {
60 name string
61 command string
62 saturated bool
63 opens func(protocol.ServerMessage) bool
64 }{
65 {name: "session picker", command: "SSP", opens: isPickerOffer},
66 {name: "session picker with full output window", command: "SSP", saturated: true, opens: isPickerOffer},
67 {name: "move picker", command: "MFP", opens: isPickerSnapshot},
68 {name: "move picker with full output window", command: "MFP", saturated: true, opens: isPickerSnapshot},
69 }
70 for _, tt := range tests {
71 t.Run(tt.name, func(t *testing.T) {
72 d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
73 defer releaseAll(releases)
74 // Production paints through the coordinator, which retries an
75 // ACK-blocked paint once the client acknowledges.
76 rc := d.attachCoordinator(source, nil, ac, true)
77 t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
78 d.handleInput(source, ac, []byte("\x1b "))
79 d.handleInput(source, ac, []byte(tt.command))
80 awaitFrame(t, sends, "Output")
81 var blocking protocol.Output
82 for _, frame := range drainAllFrames(sends) {
83 if output, ok := decodeServerMessage(t, frame).(protocol.Output); ok {
84 blocking = output
85 }
86 }
87 if tt.saturated {
88 // The palette frame is still unacknowledged: a one-frame window
89 // blocks every further paint until the client ACKs it.
90 require.NotZero(t, blocking.New)
91 ac.output.setWindow(1)
92 require.True(t, ac.output.atCapacity())
93 }
94
95 d.handleInput(source, ac, []byte("\r"))
96 require.False(t, ac.overlays.paletteActive())
97 if tt.saturated {
98 // The work runs asynchronously once released, so give a wrong
99 // early release time to reach the wire before asserting.
100 assertNoSentServerMessage(t, sends, 200*time.Millisecond, tt.opens, "picker opened before the palette-erasing frame could be sent")
101 d.handleAttachmentClientMessage(captureAttachmentCapability(source, ac, ac.transport()), protocol.Ack{Epoch: blocking.Epoch, State: blocking.New})
102 }
103
104 first := awaitSentServerMessage(t, sends, "Output or picker", func(message protocol.ServerMessage) bool {
105 _, output := message.(protocol.Output)
106 return output || tt.opens(message)
107 })
108 require.IsType(t, protocol.Output{}, first, "the palette-erasing frame must precede the picker")
109 awaitSentServerMessage(t, sends, "picker open", tt.opens)
110 })
111 }
112}
113
114func isPickerOffer(message protocol.ServerMessage) bool {
115 _, ok := message.(protocol.PickerOffer)
116 return ok
117}
118
119func isPickerSnapshot(message protocol.ServerMessage) bool {
120 _, ok := message.(protocol.PickerSnapshot)
121 return ok
122}
123
124// assertNoSentServerMessage fails if a matching message is sent within wait.
125func assertNoSentServerMessage(t *testing.T, sends <-chan wire.Envelope, wait time.Duration, match func(protocol.ServerMessage) bool, failure string) {
126 t.Helper()
127 deadline := time.After(wait)
128 for {
129 select {
130 case frame := <-sends:
131 require.False(t, match(decodeServerMessage(t, frame)), failure)
132 case <-deadline:
133 return
134 }
135 }
136}
137
138// A scripted CommandRequest has no palette to erase: its refusals stay
139// synchronous command results instead of a false success.
140func TestCommandRequestMovePickerReportsRefusalSynchronously(t *testing.T) {
141 d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
142 defer releaseAll(releases)
143 d.mu.Lock()
144 delete(d.sessions, "destination")
145 d.mu.Unlock()
146 token := captureAttachmentCapability(source, ac, ac.transport())
147 require.False(t, d.handleAttachmentClientMessage(token, protocol.CommandRequest{Version: protocol.Version, RequestID: 5, Attached: true, Slug: "move-pane"}))
148 result := awaitSentServerMessage(t, sends, "CommandResult", func(message protocol.ServerMessage) bool {
149 _, ok := message.(protocol.CommandResult)
150 return ok
151 }).(protocol.CommandResult)
152 require.NotEqual(t, protocol.CommandSucceeded, result.Outcome, "a refused move must not report success")
153 require.False(t, ac.overlays.pickerClientActive())
154}