1package client
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/vev/internal/adapters/uiterm"
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"
12 "github.com/stretchr/testify/require"
13)
14
15func newUITestService(t *testing.T) (*UI, *uiterm.Terminal, *attachPaletteClock, context.Context) {
16 t.Helper()
17 ctx, cancel := context.WithCancel(context.Background())
18 t.Cleanup(cancel)
19 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
20 require.NoError(t, err)
21 t.Cleanup(terminal.Close)
22 clock := newAttachPaletteClock()
23 u := NewUI(terminal, clock)
24 input := newTerminalInputPump(nil)
25 consumer, ok := input.tryClaim()
26 require.True(t, ok)
27 generation := u.bindForeground(ctx, input, consumer)
28 view := ports.UIContext{AttachmentHandle: u.Handle(), Generation: generation, Status: ports.UIStatusAttached, OutputEpoch: 1, OutputState: 1, ViewPublication: 1, TabID: "tab", FocusedPaneID: "pane"}
29 view.Route.Target = protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "fixture"}
30 terminal.BeginOutput(view)
31 _, err = terminal.Write([]byte("ready"))
32 require.NoError(t, err)
33 require.NoError(t, terminal.Flush())
34 terminal.EndOutput(true)
35 u.published(generation)
36 return u, terminal, clock, ctx
37}
38
39func TestUIWaitConjunctiveCurrentSnapshot(t *testing.T) {
40 u, terminal, _, ctx := newUITestService(t)
41 snapshot, err := terminal.Snapshot()
42 require.NoError(t, err)
43 text := "ready"
44 status := ports.UIStatusAttached
45 result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text, Session: &snapshot.Context.Route.Target, Focus: &ports.UIFocus{TabID: "tab", PaneID: "pane"}, Status: &status}})
46 require.NoError(t, err)
47 require.Equal(t, snapshot.Revision, result.Revision)
48 require.Equal(t, "ready \n ", uiSnapshotText(snapshot))
49 wrong := "wrong"
50 require.False(t, uiMatches(snapshot, ports.UIExpect{TextContains: &text, Focus: &ports.UIFocus{TabID: "tab", PaneID: domain.PaneStableID(wrong)}}))
51}
52
53func TestUIActionTimeoutRetainsExactReceiptBoundary(t *testing.T) {
54 u, terminal, clock, ctx := newUITestService(t)
55 errors := make(chan error, 1)
56 go func() {
57 _, err := u.Action(ctx, ports.UIActionRequest{Attachment: u.Handle(), Generation: 1, Text: "x"})
58 errors <- err
59 }()
60 var batch terminalAutomationRequest
61 select {
62 case batch = <-u.input.automation:
63 case <-time.After(time.Second):
64 t.Fatal("action did not reach owner")
65 }
66 requestTimer := requireTimer(t, clock.timers, "missing request deadline")
67 require.True(t, batch.owner.accept(batch.record.actionID, batch.record.generation))
68 batch.admitted <- true
69 batch.dispatched <- true
70 requireTimer(t, clock.timers, "missing retained action expiry")
71 requestTimer.fire()
72 var actionErr *ports.UIError
73 select {
74 case err := <-errors:
75 require.ErrorAs(t, err, &actionErr)
76 case <-time.After(time.Second):
77 t.Fatal("request deadline stalled")
78 }
79 require.Equal(t, ports.UIErrTimeout, actionErr.Code)
80 require.True(t, actionErr.Accepted)
81 require.Equal(t, batch.record.actionID, actionErr.ActionID)
82 // A receipt for another publication cannot use an earlier matching screen.
83 u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed})
84 u.mu.Lock()
85 require.Equal(t, ports.UIActionPending, u.records[actionErr.ActionID].Status)
86 u.mu.Unlock()
87 snapshot, err := terminal.Snapshot()
88 require.NoError(t, err)
89 view := snapshot.Context
90 view.ViewPublication = 2
91 require.NoError(t, terminal.PublishContext(view))
92 u.published(1)
93 boundary, err := terminal.Snapshot()
94 require.NoError(t, err)
95 // A later committed publication at the same generation must not shift the
96 // action's lower bound: the retained receipt still matches the boundary it
97 // named, never the newer revision.
98 view.Status = ports.UIStatusAttached
99 require.NoError(t, terminal.PublishContext(view))
100 u.receipt(1, protocol.UIReceipt{ActionID: actionErr.ActionID, Epoch: 1, State: 1, ViewPublication: 2, Outcome: protocol.UIReceiptProcessed})
101 u.mu.Lock()
102 record := u.records[actionErr.ActionID]
103 u.mu.Unlock()
104 require.Equal(t, ports.UIActionProcessed, record.Status)
105 require.Equal(t, boundary.Revision, record.Revision)
106 text := "ready"
107 result, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: record.ActionID, Expect: ports.UIExpect{TextContains: &text}})
108 require.NoError(t, err)
109 require.GreaterOrEqual(t, result.Revision, record.Revision)
110}
111
112func TestUIWaitBroadcastAndAttachmentQuota(t *testing.T) {
113 u, terminal, clock, ctx := newUITestService(t)
114 observeCtx, cancel := context.WithCancel(ctx)
115 done := make(chan struct{})
116 go func() { defer close(done); u.Observe(observeCtx) }()
117 t.Cleanup(func() { cancel(); requireSignal(t, done, "observation worker did not stop") })
118 status := ports.UIStatusConnecting
119 results := make(chan error, 4)
120 request := ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{Status: &status}}
121 for range 4 {
122 go func() { _, err := u.Wait(ctx, request); results <- err }()
123 requireTimer(t, clock.timers, "wait was not admitted")
124 }
125 _, err := u.Wait(ctx, request)
126 var uiErr *ports.UIError
127 require.ErrorAs(t, err, &uiErr)
128 require.Equal(t, ports.UIErrBusy, uiErr.Code)
129 snapshot, err := terminal.Snapshot()
130 require.NoError(t, err)
131 snapshot.Context.Status = status
132 require.NoError(t, terminal.PublishContext(snapshot.Context))
133 for range 4 {
134 select {
135 case err := <-results:
136 require.NoError(t, err)
137 case <-time.After(time.Second):
138 t.Fatal("coalesced publication did not wake every waiter")
139 }
140 }
141}
142
143func TestUIWaitForSnapshotUsesObservationBroadcast(t *testing.T) {
144 u, terminal, _, ctx := newUITestService(t)
145 observeCtx, cancel := context.WithCancel(ctx)
146 observed := make(chan struct{})
147 go func() {
148 u.Observe(observeCtx)
149 close(observed)
150 }()
151 defer func() {
152 cancel()
153 select {
154 case <-observed:
155 case <-time.After(time.Second):
156 t.Fatal("observation worker did not stop")
157 }
158 }()
159
160 status := ports.UIStatusConnecting
161 result := make(chan ports.UISnapshot, 1)
162 go func() {
163 snapshot, err := u.WaitForSnapshot(ctx, func(snapshot ports.UISnapshot) bool {
164 return snapshot.Context.Status == status
165 })
166 if err != nil {
167 return
168 }
169 result <- snapshot
170 }()
171 current, err := terminal.Snapshot()
172 require.NoError(t, err)
173 current.Context.Status = status
174 require.NoError(t, terminal.PublishContext(current.Context))
175 select {
176 case snapshot := <-result:
177 require.Equal(t, status, snapshot.Context.Status)
178 case <-time.After(time.Second):
179 t.Fatal("UI observation broadcast did not wake snapshot waiter")
180 }
181}
182
183func TestUIActionHistoryEvictsOnlyOnAcceptance(t *testing.T) {
184 u, _, _, _ := newUITestService(t)
185 for id := uint64(1); id <= uiActionHistory; id++ {
186 u.pending = id
187 u.reservedContext = ports.UIContext{Generation: 1}
188 require.True(t, u.accept(id, 1))
189 }
190 u.pending = 65
191 require.False(t, u.accept(65, 2))
192 require.Len(t, u.records, uiActionHistory)
193 require.Contains(t, u.records, uint64(1), "a refused generation cannot evict a retained result")
194 require.True(t, u.accept(65, 1))
195 require.Len(t, u.records, uiActionHistory)
196 require.NotContains(t, u.records, uint64(1))
197}
198
199func TestUIWaitFakeDeadlineAndExpiredAction(t *testing.T) {
200 u, _, clock, ctx := newUITestService(t)
201 text := "not present"
202 errors := make(chan error, 1)
203 go func() {
204 _, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), Expect: ports.UIExpect{TextContains: &text}})
205 errors <- err
206 }()
207 requireTimer(t, clock.timers, "missing wait timer").fire()
208 var uiErr *ports.UIError
209 select {
210 case err := <-errors:
211 require.ErrorAs(t, err, &uiErr)
212 case <-time.After(time.Second):
213 t.Fatal("wait deadline stalled")
214 }
215 require.Equal(t, ports.UIErrTimeout, uiErr.Code)
216 _, err := u.Wait(ctx, ports.UIWaitRequest{Attachment: u.Handle(), AfterAction: 99, Expect: ports.UIExpect{TextContains: &text}})
217 require.ErrorAs(t, err, &uiErr)
218 require.Equal(t, ports.UIErrActionExpired, uiErr.Code)
219}