v3.4.0 593c1c3

apps_watch_test.go RawHistoryFolder

1package cli
2
3// Tests for 202 deploy polling and `apps operations watch`: state-change
4// progress, machine-readable JSON, terminal rendering/exit, bounded
5// transient failures, fast 404, and Ctrl-C resume without server abort.
6
7import (
8	"bytes"
9	"context"
10	"encoding/json"
11	"errors"
12	"io"
13	"net/http"
14	"strings"
15	"testing"
16	"time"
17
18	"github.com/stretchr/testify/assert"
19	"github.com/stretchr/testify/mock"
20	"github.com/stretchr/testify/require"
21
22	"github.com/bnema/gordon/internal/adapters/dto"
23	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
24)
25
26// shortenOperationPoll makes the polling cadence near-instant for tests.
27func shortenOperationPoll(t *testing.T) {
28	t.Helper()
29	prev := operationPollInterval
30	operationPollInterval = time.Millisecond
31	t.Cleanup(func() { operationPollInterval = prev })
32}
33
34func runningOp(op string, steps ...dto.AppStepDTO) *dto.AppDeployResponse {
35	return &dto.AppDeployResponse{Op: op, App: "blog", Status: dto.AppStatusRunning, Steps: steps}
36}
37
38func TestRunAppDeploy_PollsAcceptedToTerminal(t *testing.T) {
39	shortenOperationPoll(t)
40	plane := appPlane(t)
41	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
42		runningOp("op-1"), "key-1", nil).Once()
43	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
44		runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
45	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
46		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
47
48	var out, errOut bytes.Buffer
49	require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
50	text := out.String()
51	assert.Contains(t, text, "op op-1:")
52	assert.Contains(t, text, "running")
53	assert.Contains(t, text, "success")
54}
55
56func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) {
57	shortenOperationPoll(t)
58	plane := appPlane(t)
59	step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
60	unchanged := runningOp("op-1", step)
61	advanced := runningOp("op-1",
62		dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
63		dto.AppStepDTO{ID: "service.web.health", State: "pending"})
64
65	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
66	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(unchanged, nil).Once()
67	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
68	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
69		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
70
71	var out, errOut bytes.Buffer
72	require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
73	assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
74		"initial and one advanced poll render progress; the unchanged poll is not reprinted")
75}
76
77func TestRunAppDeploy_AcceptedIssuesNoSecondMutation(t *testing.T) {
78	shortenOperationPoll(t)
79	plane := appPlane(t)
80	// .Once() on DeployApp proves the accepted path never re-POSTs; the
81	// by-key lookups are the only way the outcome is observed.
82	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
83	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
84		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
85
86	var out, errOut bytes.Buffer
87	require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false))
88}
89
90func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
91	shortenOperationPoll(t)
92	plane := appPlane(t)
93	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
94	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
95		Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
96		Services: map[string]dto.AppServiceResultDTO{
97			"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
98		},
99	}, nil).Once()
100
101	var out, errOut bytes.Buffer
102	err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, false)
103	require.Error(t, err, "a terminal partial outcome must exit nonzero")
104	assert.Contains(t, err.Error(), "partial")
105	assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
106}
107
108func TestRunAppDeploy_JSONKeepsStdoutMachineReadable(t *testing.T) {
109	shortenOperationPoll(t)
110	plane := appPlane(t)
111	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
112		runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
113	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
114		runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
115	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
116		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
117
118	var out, errOut bytes.Buffer
119	require.NoError(t, runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &errOut, true))
120
121	dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
122	var got dto.AppDeployResponse
123	require.NoError(t, dec.Decode(&got))
124	assert.Equal(t, "op-1", got.Op)
125	assert.Equal(t, "success", got.Outcome)
126	var extra json.RawMessage
127	assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
128	assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
129	assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130}
131
132func TestWatchOperation_CancelPrintsResumeWithoutAbort(t *testing.T) {
133	plane := appPlane(t)
134	ctx, cancel := context.WithCancel(context.Background())
135	cancel()
136
137	var out, errOut bytes.Buffer
138	err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, false)
139	require.Error(t, err)
140	assert.ErrorIs(t, err, context.Canceled)
141	text := out.String()
142	assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
143	assert.Contains(t, text, "not cancelled")
144	assert.NotContains(t, text, "cancel op")
145}
146
147func TestWatchOperation_CancelKeepsJSONStdoutClean(t *testing.T) {
148	plane := appPlane(t)
149	ctx, cancel := context.WithCancel(context.Background())
150	cancel()
151
152	var out, errOut bytes.Buffer
153	err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, true)
154	require.Error(t, err)
155	assert.Empty(t, out.String(), "JSON stdout stays empty on interruption")
156	assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
157}
158
159func TestRunAppsOperationsWatch_ResumesExisting(t *testing.T) {
160	shortenOperationPoll(t)
161	plane := appPlane(t)
162	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
163		runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
164	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
165		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
166
167	var out, errOut bytes.Buffer
168	require.NoError(t, runAppsOperationsWatch(context.Background(), plane, &out, &errOut, "blog", "key-1", false))
169	assert.Contains(t, out.String(), "success")
170}
171
172func TestAwaitOperation_TransientErrorsAreBounded(t *testing.T) {
173	shortenOperationPoll(t)
174	plane := appPlane(t)
175	boom := errors.New("connection refused")
176	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(nil, boom).Times(operationPollMaxTransientErrors + 1)
177
178	var progress bytes.Buffer
179	_, err := awaitOperation(context.Background(), plane, "blog", "key-1", nil, &progress)
180	require.Error(t, err)
181	assert.Contains(t, err.Error(), "gave up watching")
182	assert.Contains(t, err.Error(), "gordon apps operations watch blog --key key-1")
183	assert.Contains(t, progress.String(), "transient polling error")
184}
185
186func TestAwaitOperation_NotFoundFailsFast(t *testing.T) {
187	plane := appPlane(t)
188	plane.EXPECT().OperationByKey(mock.Anything, "blog", "gone").Return(nil,
189		&remote.HTTPError{StatusCode: http.StatusNotFound, Status: "404 Not Found", Body: "unknown operation"}).Once()
190
191	_, err := awaitOperation(context.Background(), plane, "blog", "gone", nil, &bytes.Buffer{})
192	require.Error(t, err)
193	assert.Contains(t, err.Error(), "no longer recorded")
194}
195
196func TestAppsOperationsWatchCmd_Flags(t *testing.T) {
197	cmd := newAppsOperationsWatchCmd()
198	assert.Equal(t, "watch APP", cmd.Use)
199	assert.NotNil(t, cmd.Flags().Lookup("key"))
200	assert.NotNil(t, cmd.Flags().Lookup("json"))
201
202	subs := map[string]bool{}
203	for _, sub := range newAppsOperationsCmd().Commands() {
204		subs[sub.Name()] = true
205	}
206	assert.True(t, subs["show"])
207	assert.True(t, subs["watch"])
208}

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