1package main
2
3import (
4 "encoding/json"
5 "errors"
6 "os"
7 "strings"
8 "testing"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12 "github.com/stretchr/testify/require"
13)
14
15const (
16 probeTestCols = 32
17 probeTestRows = 2
18)
19
20type probeTestTransport struct {
21 sent []ports.Frame
22}
23
24func (t *probeTestTransport) Send(frame ports.Frame) error {
25 t.sent = append(t.sent, frame)
26 return nil
27}
28
29func (*probeTestTransport) Recv() (ports.Frame, error) { return ports.Frame{}, errors.New("not used") }
30func (*probeTestTransport) Close() error { return nil }
31
32func probeTestOutput(t *testing.T, output ports.Output) ports.Frame {
33 t.Helper()
34 payload, err := ports.MarshalOutput(output)
35 if err != nil {
36 t.Fatalf("marshal output: %v", err)
37 }
38 return ports.Frame{Type: ports.MsgOutput, Payload: payload}
39}
40
41func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
42 size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
43 probe := newVisualProbe(size)
44 for _, tt := range []struct {
45 name string
46 output ports.Output
47 want string
48 state uint64
49 }{
50 {
51 name: "full",
52 output: ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
53 want: "full",
54 state: 1,
55 },
56 {
57 name: "incremental",
58 output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte(" incremental")},
59 want: "full incremental",
60 state: 2,
61 },
62 } {
63 t.Run(tt.name, func(t *testing.T) {
64 result := probe.apply(tt.output)
65 if !result.Accepted || !result.StateBearing {
66 t.Fatalf("result = %+v, want accepted state-bearing output", result)
67 }
68 if result.Ack.Epoch != tt.output.Epoch || result.Ack.State != tt.state {
69 t.Fatalf("ack = %+v, want epoch=%d state=%d", result.Ack, tt.output.Epoch, tt.state)
70 }
71 if !strings.Contains(probe.text(), tt.want) {
72 t.Fatalf("screen = %q, want %q", probe.text(), tt.want)
73 }
74 if probe.state.state != tt.state {
75 t.Fatalf("state = %d, want %d", probe.state.state, tt.state)
76 }
77 })
78 }
79 if got := len(probe.checkpoints); got != 2 {
80 t.Fatalf("checkpoints = %d, want 2", got)
81 }
82 if got := len(probe.events); got != 2 {
83 t.Fatalf("events = %d, want 2", got)
84 }
85}
86
87func TestVisualProbeResizesToAcceptedOutput(t *testing.T) {
88 probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
89 outputSize := domain.Size{Cols: 48, Rows: 3}
90 result := probe.apply(ports.Output{
91 Epoch: 1, New: 1, Full: true, ViewRevision: 1, Size: outputSize, Data: []byte("resized"),
92 })
93
94 require.True(t, result.Accepted)
95 require.Equal(t, outputSize.Cols, probe.screen.Frame.Width)
96 require.Equal(t, outputSize.Rows, probe.screen.Frame.Height)
97 require.Len(t, probe.checkpoints, 1)
98 require.Equal(t, outputSize.Cols, probe.checkpoints[0].snapshot.Columns())
99 require.Equal(t, outputSize.Rows, probe.checkpoints[0].snapshot.Rows())
100}
101
102func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) {
103 size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
104 for _, tt := range []struct {
105 name string
106 output ports.Output
107 }{
108 {
109 name: "base gap",
110 output: ports.Output{Epoch: 1, Base: 0, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("rejected")},
111 },
112 {
113 name: "revision gap",
114 output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 5, Size: size, Data: []byte("rejected")},
115 },
116 {
117 name: "stale epoch",
118 output: ports.Output{Epoch: 0, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte("rejected")},
119 },
120 } {
121 t.Run(tt.name, func(t *testing.T) {
122 probe := newVisualProbe(size)
123 first := ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")}
124 if result := probe.apply(first); !result.Accepted {
125 t.Fatal("seed output was rejected")
126 }
127 beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)
128 result := probe.apply(tt.output)
129 if result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
130 t.Fatalf("result = %+v, want rejected output without ACK", result)
131 }
132 if got := probe.text(); got != beforeText {
133 t.Fatalf("screen changed from %q to %q", beforeText, got)
134 }
135 if probe.state != beforeState {
136 t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
137 }
138 if len(probe.checkpoints) != beforeCheckpoints {
139 t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints)
140 }
141 })
142 }
143}
144
145func TestVisualProbeRejectsInvalidUninitializedSideEffects(t *testing.T) {
146 size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
147 for _, output := range []ports.Output{
148 {Epoch: 1, Base: 1, Size: size, Data: []byte("invalid base")},
149 {Epoch: 1, Full: true, Size: size, Data: []byte("invalid full")},
150 } {
151 probe := newVisualProbe(size)
152 result := probe.apply(output)
153 if result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
154 t.Fatalf("result = %+v, want rejected uninitialized side effect", result)
155 }
156 require.Len(t, probe.events, 1)
157 require.False(t, probe.events[0].Accepted)
158 require.Empty(t, probe.checkpoints)
159 }
160}
161
162func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) {
163 size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
164 for _, tt := range []struct {
165 name string
166 seed *ports.Output
167 effect ports.Output
168 }{
169 {
170 name: "before state-bearing output",
171 effect: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
172 },
173 {
174 name: "after state-bearing output",
175 seed: &ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
176 effect: ports.Output{Epoch: 1, ViewRevision: 4, Size: size, Data: []byte(" side effect")},
177 },
178 } {
179 t.Run(tt.name, func(t *testing.T) {
180 probe := newVisualProbe(size)
181 if tt.seed != nil && !probe.apply(*tt.seed).Accepted {
182 t.Fatal("seed output was rejected")
183 }
184 beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)
185 result := probe.apply(tt.effect)
186 if !result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
187 t.Fatalf("result = %+v, want accepted side effect without ACK", result)
188 }
189 if !strings.Contains(probe.text(), string(tt.effect.Data)) {
190 t.Fatalf("screen = %q, want side effect %q", probe.text(), tt.effect.Data)
191 }
192 if probe.state != beforeState {
193 t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
194 }
195 if len(probe.checkpoints) != beforeCheckpoints+1 {
196 t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints+1)
197 }
198 })
199 }
200}
201
202func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) {
203 size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
204 for _, tt := range []struct {
205 name string
206 seed *ports.Output
207 output ports.Output
208 wantACKs int
209 wantResets int
210 }{
211 {
212 name: "accepted full",
213 output: ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
214 wantACKs: 1,
215 },
216 {
217 name: "accepted side effect",
218 output: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
219 },
220 {
221 name: "rejected state",
222 seed: &ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
223 output: ports.Output{Epoch: 1, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("gap")},
224 wantResets: 1,
225 },
226 } {
227 t.Run(tt.name, func(t *testing.T) {
228 transport := &probeTestTransport{}
229 tr := &harnessTransport{Transport: transport, probe: newVisualProbe(size)}
230 if tt.seed != nil {
231 if err := processOutputFrame(tr, probeTestOutput(t, *tt.seed)); err != nil {
232 t.Fatal(err)
233 }
234 transport.sent = nil
235 }
236 if err := processOutputFrame(tr, probeTestOutput(t, tt.output)); err != nil {
237 t.Fatal(err)
238 }
239 var ackCount, resetCount int
240 for _, frame := range transport.sent {
241 switch frame.Type {
242 case ports.MsgAck:
243 ackCount++
244 case ports.MsgOutputResetRequest:
245 resetCount++
246 }
247 }
248 if ackCount != tt.wantACKs {
249 t.Fatalf("ACK count = %d, want %d", ackCount, tt.wantACKs)
250 }
251 if resetCount != tt.wantResets {
252 t.Fatalf("reset count = %d, want %d", resetCount, tt.wantResets)
253 }
254 })
255 }
256}
257
258func TestCaptureUnifiedPickerRowsRequiresLocalAndRemoteEntries(t *testing.T) {
259 probe := newVisualProbe(domain.Size{Cols: 48, Rows: 4})
260 probe.screen.Write([]byte("local-picker\npicker@remote"))
261
262 rows := capturePickerRows(probe)
263 if err := assertUnifiedPickerRows(rows, "local-picker", "picker"); err != nil {
264 t.Fatalf("assert unified picker rows: %v; rows = %#v", err, rows)
265 }
266}
267
268func TestVisualProbeRecordsUnexpectedAttachTarget(t *testing.T) {
269 probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
270 probe.recordIncoming(ports.Frame{Type: ports.MsgAttachTarget})
271
272 if probe.unexpectedHandoffs != 1 {
273 t.Fatalf("unexpected handoffs = %d, want 1", probe.unexpectedHandoffs)
274 }
275 if len(probe.events) != 1 || probe.events[0].Kind != probeEventUnexpectedHandoff {
276 t.Fatalf("events = %#v, want one unexpected handoff event", probe.events)
277 }
278}
279
280func TestAssertNoRemoteChromeIgnoresLocalBarsButRejectsContentLeak(t *testing.T) {
281 require.Error(t, assertNoRemoteChrome(nil, "picker"))
282 for _, test := range []struct {
283 name string
284 content string
285 wantErr bool
286 }{
287 {name: "local bars", content: "picker@remote\r\nremote content\r\nlocal bottom", wantErr: false},
288 {name: "remote status in content", content: "local top\r\npicker@remote\r\nlocal bottom", wantErr: true},
289 } {
290 t.Run(test.name, func(t *testing.T) {
291 probe := newVisualProbe(domain.Size{Cols: 48, Rows: 3})
292 probe.screen.Write([]byte(test.content))
293 err := assertNoRemoteChrome(probe, "picker")
294 if (err != nil) != test.wantErr {
295 t.Fatalf("error = %v, want error = %t", err, test.wantErr)
296 }
297 })
298 }
299}
300
301func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) {
302 dir := t.TempDir()
303 artifact := newHarnessArtifact(dir)
304 size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
305 probe := newVisualProbe(size)
306 probe.configure("local-picker")
307 artifact.registerProbe(probe)
308 secret := "not-for-artifact"
309 for state := uint64(1); state <= maxProbeEvents+1; state++ {
310 output := ports.Output{Epoch: 1, Base: state - 1, New: state, Size: size, Data: []byte(secret)}
311 if state == 1 {
312 output.Full = true
313 }
314 if result := probe.apply(output); !result.Accepted {
315 t.Fatalf("probe output %d was rejected", state)
316 }
317 }
318 if err := artifact.write(true); err != nil {
319 t.Fatal(err)
320 }
321 path := dir + "/remote-picker-harness.json"
322 data, err := os.ReadFile(path)
323 if err != nil {
324 t.Fatal(err)
325 }
326 if len(data) > maxArtifactBytes {
327 t.Fatalf("artifact bytes = %d, want <= %d", len(data), maxArtifactBytes)
328 }
329 if strings.Contains(string(data), secret) {
330 t.Fatal("artifact contained raw output")
331 }
332 var document artifactDocument
333 if err := json.Unmarshal(data, &document); err != nil {
334 t.Fatalf("artifact JSON: %v", err)
335 }
336 if !document.Passed || len(document.Probes) != 1 {
337 t.Fatalf("artifact document = %+v, want one passed probe", document)
338 }
339 if len(document.Probes[0].Events) != maxProbeEvents {
340 t.Fatalf("artifact events = %d, want %d", len(document.Probes[0].Events), maxProbeEvents)
341 }
342 if len(document.Probes[0].Checkpoints) != maxProbeCheckpoints {
343 t.Fatalf("artifact checkpoints = %d, want %d", len(document.Probes[0].Checkpoints), maxProbeCheckpoints)
344 }
345}