1package remote
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "os/exec"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14
15 renderer "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/adapters/sshstdio"
17 "github.com/bnema/vev/internal/domain"
18 "github.com/bnema/vev/internal/ports"
19)
20
21func previewClientTargetForTest() domain.RemoteSessionTarget {
22 var lifecycle domain.SessionLifecycleID
23 lifecycle[0] = 1
24 return domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
25}
26
27func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T) {
28 var lifecycle domain.SessionLifecycleID
29 lifecycle[0] = 9
30 target := domain.RemoteSessionTarget{
31 Endpoint: "build@mule", DisplayOrigin: "mule", LifecycleID: lifecycle,
32 SessionName: "work", LiveTabID: "tab-1",
33 }
34 want := ports.RemotePreview{
35 Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
36 LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1,
37 Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
38 }
39 payload := ports.MarshalRemotePreview(want)
40 require.NotNil(t, payload)
41
42 var gotPath string
43 var gotArgs []string
44 client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
45 gotPath = name
46 gotArgs = append([]string(nil), args...)
47 if len(args) != 3 {
48 t.Fatalf("ssh args = %#v, want target and one remote command", args)
49 }
50 words := strings.Split(args[2], " ")
51 if len(words) != 3 {
52 t.Fatalf("remote command = %q, want three shell words", args[2])
53 }
54 encoded := strings.Trim(words[2], "'")
55 requestPayload, err := base64.RawURLEncoding.DecodeString(encoded)
56 if err != nil {
57 t.Fatalf("decode request: %v", err)
58 }
59 request, err := ports.UnmarshalRemotePreviewRequest(requestPayload)
60 if err != nil {
61 t.Fatalf("decode request payload: %v", err)
62 }
63 if request.Target != target || request.Width != 1 || request.Height != 1 {
64 t.Fatalf("request = %#v, want target and 1x1 dimensions", request)
65 }
66 return stdoutCmd(ctx, string(payload))
67 }}
68
69 got, err := client.Preview(context.Background(), target, 1, 1)
70 require.NoError(t, err)
71 require.Equal(t, want, got)
72 wantCommand := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", "request")
73 require.Equal(t, wantCommand.Path, gotPath)
74 require.Equal(t, target.Endpoint, gotArgs[1])
75 require.Contains(t, gotArgs[2], "'_remote-preview'")
76}
77
78func TestRemotePreviewClientRejectsMismatchedResponseIdentity(t *testing.T) {
79 target := previewClientTargetForTest()
80 var otherLifecycle domain.SessionLifecycleID
81 otherLifecycle[0] = 2
82 valid := ports.RemotePreview{
83 Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
84 LifecycleID: target.LifecycleID, TabID: target.LiveTabID, Revision: 1, Width: 1, Height: 1,
85 Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
86 }
87 tests := []struct {
88 name string
89 mutate func(*ports.RemotePreview)
90 }{
91 {name: "lifecycle ID", mutate: func(preview *ports.RemotePreview) { preview.LifecycleID = otherLifecycle }},
92 {name: "tab ID", mutate: func(preview *ports.RemotePreview) { preview.TabID = "tab-2" }},
93 }
94 for _, tt := range tests {
95 t.Run(tt.name, func(t *testing.T) {
96 preview := valid
97 tt.mutate(&preview)
98 payload := ports.MarshalRemotePreview(preview)
99 require.NotNil(t, payload)
100
101 client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
102 return stdoutCmd(ctx, string(payload))
103 }}
104 _, err := client.Preview(context.Background(), target, 1, 1)
105 require.ErrorIs(t, err, errRemotePreviewIdentity)
106 })
107 }
108}
109
110func TestRemotePreviewClientRejectsMalformedResponse(t *testing.T) {
111 client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
112 return stdoutCmd(ctx, "not-a-preview")
113 }}
114 var lifecycle domain.SessionLifecycleID
115 lifecycle[0] = 1
116 target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
117 _, err := client.Preview(context.Background(), target, 1, 1)
118 require.Error(t, err)
119}
120
121func TestRemotePreviewClientRejectsOversizedOutput(t *testing.T) {
122 client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
123 return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("head -c %d /dev/zero", ports.RemotePreviewMaxBytes+1))
124 }}
125 target := previewClientTargetForTest()
126 _, err := client.Preview(context.Background(), target, 1, 1)
127 require.ErrorIs(t, err, errRemotePreviewTooLarge)
128}
129
130func TestRemotePreviewClientReturnsStderrDiagnostic(t *testing.T) {
131 client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
132 return exec.CommandContext(ctx, "sh", "-c", "printf 'connection refused' >&2; exit 1")
133 }}
134 target := previewClientTargetForTest()
135 _, err := client.Preview(context.Background(), target, 1, 1)
136 require.ErrorIs(t, err, errRemotePreviewSSH)
137 require.Contains(t, err.Error(), "connection refused")
138}
139
140func TestRemotePreviewClientReturnsInternalTimeoutSentinel(t *testing.T) {
141 client := &PreviewClient{
142 timeout: 20 * time.Millisecond,
143 command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
144 return exec.CommandContext(ctx, "sleep", "10")
145 },
146 }
147 _, err := client.Preview(context.Background(), previewClientTargetForTest(), 1, 1)
148 require.ErrorIs(t, err, ports.ErrRemotePreviewTimeout)
149}
150
151func TestRemotePreviewClientReturnsCallerCancellation(t *testing.T) {
152 started := make(chan struct{})
153 client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
154 close(started)
155 return exec.CommandContext(ctx, "sleep", "10")
156 }}
157 target := previewClientTargetForTest()
158 ctx, cancel := context.WithCancel(context.Background())
159 defer cancel()
160 errCh := make(chan error, 1)
161 go func() {
162 _, err := client.Preview(ctx, target, 1, 1)
163 errCh <- err
164 }()
165 <-started
166 cancel()
167 err := <-errCh
168 require.ErrorIs(t, err, context.Canceled)
169 require.False(t, errors.Is(err, errRemotePreviewSSH))
170}