v3.0.0 f19f453

apps_test.go RawHistoryFolder

1package cli
2
3// Tests for the app CLI surface (apps.go). The commands go through one
4// ControlPlane served by the generated mock: JSON parity (text and --json
5// carry equivalent semantics), no secret values in output, plan-contract
6// errors, and outcome-unknown guidance.
7
8import (
9	"bytes"
10	"context"
11	"encoding/json"
12	"errors"
13	"net/http"
14	"os"
15	"path/filepath"
16	"strings"
17	"testing"
18
19	"github.com/spf13/cobra"
20	"github.com/stretchr/testify/assert"
21	"github.com/stretchr/testify/mock"
22	"github.com/stretchr/testify/require"
23
24	"github.com/bnema/gordon/internal/adapters/dto"
25	climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
26	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
27)
28
29// appPlane returns the generated control plane mock for one test.
30func appPlane(t *testing.T) *climocks.MockControlPlane {
31	t.Helper()
32	return climocks.NewMockControlPlane(t)
33}
34
35func writeManifest(t *testing.T, content string) string {
36	t.Helper()
37	path := filepath.Join(t.TempDir(), "app.toml")
38	require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
39	return path
40}
41
42func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
43	plane := appPlane(t)
44	err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "x.toml", true, true, false)
45	require.Error(t, err)
46	assert.Contains(t, err.Error(), "--dry-run")
47	assert.Contains(t, err.Error(), "--deploy")
48}
49
50func TestRunAppsApply_RequiresFile(t *testing.T) {
51	plane := appPlane(t)
52	err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "", false, false, false)
53	require.Error(t, err)
54	assert.Contains(t, err.Error(), "--file")
55}
56
57func TestRunAppsApply_JSONParity(t *testing.T) {
58	want := &dto.AppApplyResponse{
59		App: "blog", FormerRevision: "rev-a", ResultingRevision: "rev-b",
60		Pending: true, Intent: "apply-123",
61		Diff: dto.AppDiffSection{Added: []string{"service.web"}},
62	}
63	plane := appPlane(t)
64	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once()
65	var out bytes.Buffer
66	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
67		writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true))
68	var got dto.AppApplyResponse
69	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
70	assert.Equal(t, *want, got)
71}
72
73func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) {
74	plane := appPlane(t)
75	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
76		&dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
77	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
78		&dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
79	var out bytes.Buffer
80	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
81		writeManifest(t, "x"), false, true, false))
82	assert.Contains(t, out.String(), "rev-b")
83}
84
85func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) {
86	plane := appPlane(t)
87	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
88		&dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
89	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
90		&dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
91	var out bytes.Buffer
92	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
93		writeManifest(t, "x"), false, true, true))
94	var got struct {
95		Apply  dto.AppApplyResponse  `json:"apply"`
96		Deploy dto.AppDeployResponse `json:"deploy"`
97	}
98	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
99	assert.Equal(t, "apply-1", got.Apply.Intent)
100	assert.Equal(t, "op-1", got.Deploy.Op)
101}
102
103func TestRunAppsList_JSONParityAndSorting(t *testing.T) {
104	plane := appPlane(t)
105	plane.EXPECT().ListApps(mock.Anything).Return([]dto.AppSummaryDTO{
106		{App: "zeta", Converged: true},
107		{App: "alpha", Stopped: true},
108	}, nil)
109	var out bytes.Buffer
110	require.NoError(t, runAppsList(context.Background(), plane, &out, true))
111	var got []dto.AppSummaryDTO
112	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
113	require.Len(t, got, 2)
114
115	out.Reset()
116	require.NoError(t, runAppsList(context.Background(), plane, &out, false))
117	text := out.String()
118	assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta"), "text lists apps sorted")
119	assert.Contains(t, text, "stopped")
120}
121
122func TestRunAppsList_EmptyJSONIsArray(t *testing.T) {
123	plane := appPlane(t)
124	plane.EXPECT().ListApps(mock.Anything).Return(nil, nil).Once()
125	var out bytes.Buffer
126	require.NoError(t, runAppsList(context.Background(), plane, &out, true))
127	assert.JSONEq(t, `[]`, strings.TrimSpace(out.String()))
128}
129
130func TestRunAppsShow_JSONParity(t *testing.T) {
131	want := &dto.AppShowResponse{
132		App:     "blog",
133		Desired: dto.AppDesiredDTO{Revision: "rev-b", Status: "pending"},
134		Active: dto.AppActiveDTO{Converged: false, Services: map[string]dto.AppActiveServiceDTO{
135			"web": {EffectiveRevision: "rev-a", Container: "ctr-a", RestartUnsafe: true},
136		}},
137		Intent: dto.AppIntentDTO{Stopped: true},
138		LastOp: &dto.AppLastOpDTO{Op: "op-9", Outcome: "failed"},
139	}
140	plane := appPlane(t)
141	plane.EXPECT().ShowApp(mock.Anything, "blog").Return(want, nil)
142	var out bytes.Buffer
143	require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, true))
144	var got dto.AppShowResponse
145	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
146	assert.Equal(t, *want, got)
147
148	out.Reset()
149	require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false))
150	assert.Contains(t, out.String(), "restart_unsafe", "text shows WHY recovery is blocked")
151}
152
153func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) {
154	plane := appPlane(t)
155	var gotApp string
156	var gotReq dto.AppSecretSetRequest
157	plane.EXPECT().SetAppSecrets(mock.Anything, mock.Anything, mock.Anything).
158		Run(func(_ context.Context, app string, req dto.AppSecretSetRequest) {
159			gotApp, gotReq = app, req
160		}).Return(nil).Once()
161	var out bytes.Buffer
162	require.NoError(t, runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
163		&out, "blog", []string{"password=s3cr3t-hunter2", "token=abc"}, "web", false, false))
164	text := out.String()
165	assert.NotContains(t, text, "s3cr3t-hunter2")
166	assert.NotContains(t, text, "abc")
167	assert.Contains(t, text, "password")
168	assert.Contains(t, text, "token")
169	assert.Equal(t, "blog", gotApp)
170	assert.Equal(t, "web", gotReq.Service)
171	assert.Equal(t, map[string]string{"password": "s3cr3t-hunter2", "token": "abc"}, gotReq.Secrets)
172}
173
174func TestRunAppsSecretsSet_RequiresService(t *testing.T) {
175	plane := appPlane(t)
176	err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
177		&bytes.Buffer{}, "blog", []string{"k=v"}, "", false, false)
178	require.Error(t, err)
179	assert.Contains(t, err.Error(), "--service")
180}
181
182func TestRunAppsSecretsSet_StdinAndValidation(t *testing.T) {
183	plane := appPlane(t)
184	var gotReq dto.AppSecretSetRequest
185	plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything).
186		Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once()
187	var out bytes.Buffer
188	stdin := strings.NewReader("from_stdin=stdin-value\n\nspaced=  preserved  \n")
189	require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin,
190		&out, "blog", []string{"from_flag=flag-value"}, "web", true, false))
191	assert.Equal(t, map[string]string{
192		"from_stdin": "stdin-value",
193		"spaced":     "  preserved  ",
194		"from_flag":  "flag-value",
195	}, gotReq.Secrets)
196
197	err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
198		&bytes.Buffer{}, "blog", []string{"no-equals-here"}, "web", false, false)
199	require.Error(t, err)
200	assert.Contains(t, err.Error(), "KEY=VALUE")
201}
202
203func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
204	plane := appPlane(t)
205	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
206		nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()
207	err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &bytes.Buffer{}, &bytes.Buffer{}, false)
208	require.Error(t, err)
209	assert.Contains(t, err.Error(), "outcome-unknown")
210	assert.Contains(t, err.Error(), "key-abc")
211	assert.Contains(t, err.Error(), "by-key")
212}
213
214func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
215	plane := appPlane(t)
216	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
217		StatusCode: http.StatusConflict, Status: "409 Conflict",
218		Response: dto.AppDeployResponse{
219			Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
220			Services: map[string]dto.AppServiceResultDTO{
221				"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
222			},
223		},
224	}).Once()
225	var out bytes.Buffer
226	err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, false)
227	require.Error(t, err, "conflict must retain failure semantics")
228	text := out.String()
229	assert.Contains(t, text, "web:")
230	assert.Contains(t, text, "nope")
231	assert.Contains(t, err.Error(), "failed")
232	assert.Contains(t, err.Error(), "op-9")
233	assert.Contains(t, err.Error(), "key-9")
234	assert.Contains(t, err.Error(), "by-key")
235}
236
237func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
238	plane := appPlane(t)
239	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
240		StatusCode: http.StatusConflict, Status: "409 Conflict",
241		Response: dto.AppDeployResponse{
242			Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
243			Services: map[string]dto.AppServiceResultDTO{
244				"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
245			},
246		},
247	}).Once()
248	var out bytes.Buffer
249	err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, true)
250	require.Error(t, err)
251	var got dto.AppDeployResponse
252	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
253	assert.Equal(t, "op-9", got.Op)
254	assert.Equal(t, "failed", got.Services["web"].Result)
255}
256
257func TestRunAppLifecycle_ConflictRendersJournal(t *testing.T) {
258	plane := appPlane(t)
259	plane.EXPECT().StopApp(mock.Anything, "blog").Return(nil, "key-7", &remote.AppOpConflictError{
260		StatusCode: http.StatusConflict, Status: "409 Conflict",
261		Response: dto.AppDeployResponse{
262			Op: "op-7", App: "blog", Outcome: "partial",
263			Services: map[string]dto.AppServiceResultDTO{
264				"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"},
265			},
266		},
267	}).Once()
268	var out bytes.Buffer
269	err := runAppLifecycle(context.Background(), plane.StopApp, "stop", "blog", &out, false)
270	require.Error(t, err)
271	assert.Contains(t, out.String(), "busy")
272	assert.Contains(t, err.Error(), "key-7")
273	assert.Contains(t, err.Error(), "by-key")
274}
275
276func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
277	resp := &dto.AppDeployResponse{
278		Op: "op-1", App: "blog", Outcome: "partial",
279		Services: map[string]dto.AppServiceResultDTO{
280			"web": {Result: "deployed", EffectiveRevision: "rev-b"},
281			"db":  {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},
282		},
283		CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}},
284		Effective:       &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}},
285		Retained:        &dto.AppRetainedDTO{Volumes: []string{"blog-db"}},
286	}
287	var out bytes.Buffer
288	require.NoError(t, renderAppDeployResponse(&out, resp))
289	text := out.String()
290	assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
291	assert.Contains(t, text, "restart_unsafe")
292	assert.Contains(t, text, "cleanup:")
293	assert.Contains(t, text, "blog-db")
294}
295
296func TestAppLogRef_Selection(t *testing.T) {
297	multi := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
298		"web": {Container: "ctr-web"},
299		"db":  {Container: "ctr-db"},
300	}}}
301	_, err := appLogRef(multi, "")
302	require.Error(t, err)
303	assert.Contains(t, err.Error(), "--service")
304
305	ref, err := appLogRef(multi, "db")
306	require.NoError(t, err)
307	assert.Equal(t, "blog/db", ref)
308
309	_, err = appLogRef(multi, "missing")
310	require.Error(t, err)
311
312	single := &dto.AppShowResponse{App: "solo", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
313		"web": {Container: "ctr-solo"},
314	}}}
315	ref, err = appLogRef(single, "")
316	require.NoError(t, err)
317	assert.Equal(t, "solo/web", ref)
318
319	empty := &dto.AppShowResponse{App: "new", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
320		"web": {},
321	}}}
322	_, err = appLogRef(empty, "web")
323	require.Error(t, err)
324	assert.Contains(t, err.Error(), "no recorded container")
325}
326
327func TestResolveAppPlane_FailsWithoutDaemon(t *testing.T) {
328	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
329	t.Setenv("GORDON_REMOTE", "")
330	t.Setenv("GORDON_TOKEN", "")
331	origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag
332	t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure })
333	remoteFlag, tokenFlag, insecureTLSFlag = "", "", false
334
335	restore := newLocalAppClient
336	newLocalAppClient = func() (*remote.Client, error) { return nil, remote.ErrDaemonUnavailable }
337	t.Cleanup(func() { newLocalAppClient = restore })
338
339	_, err := resolveAppPlane()
340	require.Error(t, err)
341	assert.Contains(t, err.Error(), "daemon-unavailable")
342}
343
344// TestAppCommandSurface pins the frozen CLI surface (05-api-cli.md §5):
345// every command exists with its frozen Use string and flags. The commands
346// stay unregistered until cutover; this test is what keeps them referenced.
347func TestAppCommandSurface(t *testing.T) {
348	root := newAppsCmd()
349	require.Equal(t, "apps", root.Use)
350	subs := map[string]bool{}
351	for _, sub := range root.Commands() {
352		subs[sub.Name()] = true
353	}
354	for _, name := range []string{"apply", "list", "show", "diff", "secrets", "deploy", "restart", "stop", "start", "remove", "status", "logs"} {
355		assert.True(t, subs[name], "apps subcommand %s", name)
356	}
357
358	secrets := newAppsSecretsCmd()
359	secretSubs := map[string]bool{}
360	for _, sub := range secrets.Commands() {
361		secretSubs[sub.Name()] = true
362	}
363	assert.True(t, secretSubs["set"])
364	assert.True(t, secretSubs["delete"])
365
366	flagged := map[*cobra.Command][]string{
367		newAppsApplyCmd():         {"file", "dry-run", "deploy", "json"},
368		newAppsListCmd():          {"json"},
369		newAppsShowCmd():          {"json"},
370		newAppsDiffCmd():          {"json"},
371		newAppsSecretsSetCmd():    {"service", "stdin", "json"},
372		newAppsSecretsDeleteCmd(): {"service", "json"},
373		newAppDeployCmd():         {"revision", "service", "json"},
374		newAppRestartCmd():        {"service", "json"},
375		newAppStopCmd():           {"json"},
376		newAppStartCmd():          {"json"},
377		newAppRemoveCmd():         {"json"},
378		newAppStatusCmd():         {"json"},
379		newAppLogsCmd():           {"service", "follow", "tail", "json"},
380	}
381	for cmd, flags := range flagged {
382		for _, flag := range flags {
383			assert.NotNil(t, cmd.Flags().Lookup(flag), "%s must define --%s", cmd.Use, flag)
384		}
385	}
386
387	assert.Equal(t, "deploy APP", newAppDeployCmd().Use)
388	assert.Equal(t, "restart APP", newAppRestartCmd().Use)
389	assert.Equal(t, "stop APP", newAppStopCmd().Use)
390	assert.Equal(t, "start APP", newAppStartCmd().Use)
391	assert.Equal(t, "remove APP", newAppRemoveCmd().Use)
392	assert.Equal(t, "status APP", newAppStatusCmd().Use)
393	assert.Equal(t, "logs APP", newAppLogsCmd().Use)
394}
395
396func TestRunAppsDiff_TextAndJSON(t *testing.T) {
397	diff := &dto.AppDiffResponse{
398		App:  "blog",
399		Diff: dto.AppDiffSection{Added: []string{"b"}, Removed: []string{"a"}, Changed: []string{"c"}},
400	}
401	plane := appPlane(t)
402	plane.EXPECT().DiffApp(mock.Anything, "blog").Return(diff, nil).Times(2)
403	var out bytes.Buffer
404	require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
405	text := out.String()
406	assert.Contains(t, text, "added:")
407	assert.Contains(t, text, "removed:")
408	assert.Contains(t, text, "changed:")
409
410	out.Reset()
411	require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, true))
412	var got dto.AppDiffResponse
413	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
414	assert.Equal(t, *diff, got)
415
416	plane.EXPECT().DiffApp(mock.Anything, "blog").Return(&dto.AppDiffResponse{App: "blog"}, nil).Once()
417	out.Reset()
418	require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
419	assert.Contains(t, out.String(), "No differences")
420}
421
422func TestRunAppsSecretsDelete(t *testing.T) {
423	plane := appPlane(t)
424	plane.EXPECT().DeleteAppSecret(mock.Anything, "blog", dto.AppSecretDeleteRequest{Service: "web", Key: "password"}).Return(nil).Times(2)
425	var out bytes.Buffer
426	require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", false))
427	assert.Contains(t, out.String(), "password")
428
429	out.Reset()
430	require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", true))
431	var got map[string]string
432	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
433	assert.Equal(t, "password", got["deleted"])
434
435	err := runAppsSecretsDelete(context.Background(), plane, &bytes.Buffer{}, "blog", "password", "", false)
436	require.Error(t, err)
437	assert.Contains(t, err.Error(), "--service")
438}
439
440func TestRunAppRestart_AndLifecycle(t *testing.T) {
441	resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"}
442	plane := appPlane(t)
443	plane.EXPECT().RestartApp(mock.Anything, "blog", "", false).Return(resp, "key-lc", nil).Times(2)
444	plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
445	plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
446	plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
447	var out bytes.Buffer
448	require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, false))
449	assert.Contains(t, out.String(), "success")
450
451	out.Reset()
452	require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, true))
453	var got dto.AppDeployResponse
454	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
455	assert.Equal(t, *resp, got)
456
457	for _, fn := range []appLifecycleFunc{plane.StopApp, plane.StartApp, plane.RemoveApp} {
458		out.Reset()
459		require.NoError(t, runAppLifecycle(context.Background(), fn, "stop", "blog", &out, false))
460		assert.Contains(t, out.String(), "op-2")
461	}
462
463	unknown := appPlane(t)
464	unknown.EXPECT().StopApp(mock.Anything, "blog").Return(
465		nil, "key-x", &remote.OutcomeUnknownError{Method: "POST", Path: "/x", Err: errors.New("boom")}).Once()
466	err := runAppLifecycle(context.Background(), unknown.StopApp, "stop", "blog", &bytes.Buffer{}, false)
467	require.Error(t, err)
468	assert.Contains(t, err.Error(), "key-x")
469}
470
471func TestRunAppStatus_Observed(t *testing.T) {
472	show := &dto.AppShowResponse{
473		App: "blog",
474		Active: dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
475			"web": {EffectiveRevision: "rev-b", Container: "ctr-b"},
476		}},
477	}
478	plane := appPlane(t)
479	plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(2)
480	var out bytes.Buffer
481	require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, false))
482	assert.Contains(t, out.String(), "ctr-b")
483
484	out.Reset()
485	require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, true))
486	var got dto.AppShowResponse
487	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
488	assert.Equal(t, *show, got)
489}
490
491// fakeAppLogReader scripts container log reads for app log tests.
492type fakeAppLogReader struct {
493	lines  []string
494	stream []string
495}
496
497func (f *fakeAppLogReader) GetContainerLogs(_ context.Context, _ string, _ int) ([]string, error) {
498	return f.lines, nil
499}
500
501func (f *fakeAppLogReader) StreamContainerLogs(_ context.Context, _ string, _ int) (<-chan string, error) {
502	ch := make(chan string, len(f.stream))
503	for _, line := range f.stream {
504		ch <- line
505	}
506	close(ch)
507	return ch, nil
508}
509
510func TestRunAppLogs_TextJSONFollow(t *testing.T) {
511	show := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
512		"web": {Container: "ctr-web"},
513	}}}
514	plane := appPlane(t)
515	plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(3)
516	reader := &fakeAppLogReader{lines: []string{"l1", "l2"}, stream: []string{"s1"}}
517
518	var out bytes.Buffer
519	require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "", false, 50, &out, false))
520	assert.Equal(t, "l1\nl2\n", out.String())
521
522	out.Reset()
523	require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", false, 50, &out, true))
524	var got map[string]any
525	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
526	assert.Equal(t, "blog", got["app"])
527
528	out.Reset()
529	require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false))
530	assert.Equal(t, "s1\n", out.String())
531}
532
533func TestRunAppsSecretsList_RendersMetadataAndWrapsErrors(t *testing.T) {
534	plane := appPlane(t)
535	plane.EXPECT().ListAppSecrets(mock.Anything, "blog", "web").Return([]dto.AppSecretMetadataDTO{
536		{Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
537	}, nil).Once()
538	var out bytes.Buffer
539	require.NoError(t, runAppsSecretsList(context.Background(), plane, &out, "blog", "web", false))
540	assert.Contains(t, out.String(), "web/DATABASE_URL")
541	assert.Contains(t, out.String(), "desired")
542
543	errPlane := appPlane(t)
544	errPlane.EXPECT().ListAppSecrets(mock.Anything, "blog", "").Return(nil, errors.New("boom")).Once()
545	err := runAppsSecretsList(context.Background(), errPlane, &bytes.Buffer{}, "blog", "", false)
546	require.Error(t, err)
547	assert.Contains(t, err.Error(), "list app secrets for blog")
548	assert.Contains(t, err.Error(), "boom")
549}

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 7

Tags 106

↑↓ moveenter openesc close