apps_apply_watch_test.go RawHistoryFolder

1package cli
2
3// Tests for the `apps apply --deploy` chain over a 202 accepted deploy:
4// it must reuse the shared by-key watch (progress only on change, terminal
5// journal, Ctrl-C resume) and must never reissue the deploy POST. JSON mode
6// keeps stdout to exactly one final combined {apply,deploy} document while
7// progress and resume guidance go to stderr.
8
9import (
10	"bytes"
11	"context"
12	"encoding/json"
13	"io"
14	"strings"
15	"testing"
16
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/mock"
19	"github.com/stretchr/testify/require"
20
21	"github.com/bnema/gordon/internal/adapters/dto"
22)
23
24func acceptedApply() *dto.AppApplyResponse {
25	return &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}
26}
27
28func TestRunAppsApply_DeployAcceptedPollsToSuccess(t *testing.T) {
29	shortenOperationPoll(t)
30	plane := appPlane(t)
31	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
32	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
33		runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
34	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
35		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
36
37	var out, errOut bytes.Buffer
38	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
39		writeManifest(t, "[app]\nname = \"blog\"\n"), false, true, false))
40	text := out.String()
41	assert.Contains(t, text, "Applied blog (rev-b)", "the apply outcome stays visible")
42	assert.Contains(t, text, "op op-1:", "the accepted deploy reports progress through the watch path")
43	assert.Contains(t, text, "op-1 blog: success", "the terminal journal is rendered")
44}
45
46func TestRunAppsApply_DeployAcceptedNoSecondMutation(t *testing.T) {
47	shortenOperationPoll(t)
48	plane := appPlane(t)
49	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
50	// .Once() proves the accepted deploy is observed by key, never re-POSTed.
51	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
52	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
53		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
54
55	var out, errOut bytes.Buffer
56	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
57		writeManifest(t, "x"), false, true, false))
58}
59
60func TestRunAppsApply_DeployAcceptedUnchangedStateNotReprinted(t *testing.T) {
61	shortenOperationPoll(t)
62	plane := appPlane(t)
63	step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
64	advanced := runningOp("op-1",
65		dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
66		dto.AppStepDTO{ID: "service.web.health", State: "pending"})
67
68	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
69	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
70	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(runningOp("op-1", step), nil).Once()
71	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
72	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
73		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
74
75	var out, errOut bytes.Buffer
76	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
77		writeManifest(t, "x"), false, true, false))
78	assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
79		"initial and one advanced poll render progress; the unchanged poll is not reprinted")
80}
81
82func TestRunAppsApply_DeployAcceptedPartialFailsButKeepsApply(t *testing.T) {
83	shortenOperationPoll(t)
84	plane := appPlane(t)
85	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
86	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
87	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
88		Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
89		Services: map[string]dto.AppServiceResultDTO{
90			"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
91		},
92	}, nil).Once()
93
94	var out, errOut bytes.Buffer
95	err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
96		writeManifest(t, "x"), false, true, false)
97	require.Error(t, err, "a terminal partial deploy must exit nonzero")
98	assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
99	assert.Contains(t, err.Error(), "partial")
100	assert.Contains(t, out.String(), "Applied blog (rev-b)", "the apply outcome stays visible")
101	assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
102}
103
104func TestRunAppsApply_DeployAcceptedJSONKeepsStdoutSingleCombinedDocument(t *testing.T) {
105	shortenOperationPoll(t)
106	plane := appPlane(t)
107	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
108	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
109		runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
110	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
111		runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
112	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
113		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
114
115	var out, errOut bytes.Buffer
116	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
117		writeManifest(t, "x"), false, true, true))
118
119	dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
120	var got appApplyDeployDocument
121	require.NoError(t, dec.Decode(&got))
122	assert.Equal(t, "apply-1", got.Apply.Intent)
123	assert.Equal(t, "op-1", got.Deploy.Op)
124	assert.Equal(t, "success", got.Deploy.Outcome)
125	var extra json.RawMessage
126	assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
127	assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
128	assert.NotContains(t, out.String(), dto.AppStatusRunning, "no initial running JSON is emitted")
129	assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130}
131
132func TestRunAppsApply_DeployAcceptedJSONFailureKeepsApplyDocument(t *testing.T) {
133	shortenOperationPoll(t)
134	plane := appPlane(t)
135	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
136	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
137	plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
138		&dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "failed", Outcome: "failed"}, nil).Once()
139
140	var out, errOut bytes.Buffer
141	err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
142		writeManifest(t, "x"), false, true, true)
143	require.Error(t, err)
144	assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
145
146	dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
147	var got appApplyDeployDocument
148	require.NoError(t, dec.Decode(&got))
149	assert.Equal(t, "rev-b", got.Apply.ResultingRevision, "the failed deploy must not hide the successful apply")
150	assert.Equal(t, "failed", got.Deploy.Outcome)
151	var extra json.RawMessage
152	assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
153}
154
155func TestRunAppsApply_DeployAcceptedCancelPrintsResume(t *testing.T) {
156	plane := appPlane(t)
157	ctx, cancel := context.WithCancel(context.Background())
158	cancel()
159	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
160	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
161
162	var out, errOut bytes.Buffer
163	err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
164		writeManifest(t, "x"), false, true, false)
165	require.Error(t, err)
166	assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
167	text := out.String()
168	assert.Contains(t, text, "not cancelled", "Ctrl-C must not imply a server-side abort")
169	assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
170}
171
172func TestRunAppsApply_DeployAcceptedCancelKeepsJSONStdoutClean(t *testing.T) {
173	plane := appPlane(t)
174	ctx, cancel := context.WithCancel(context.Background())
175	cancel()
176	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
177	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
178
179	var out, errOut bytes.Buffer
180	err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
181		writeManifest(t, "x"), false, true, true)
182	require.Error(t, err)
183	assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
184	assert.Empty(t, out.String(), "JSON stdout stays empty when interrupted before terminal")
185	assert.Contains(t, errOut.String(), "not cancelled")
186	assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
187}

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