carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Tests for `carlos pipeline` (Task 10, release pipelines v2) and the
2// promote/rollback proposed-response handling it shares with promote and
3// rollback. In-process, stub-console pattern (TestPromoteVerbAPIMode,
4// apimode_test.go) throughout: useTempCredentials/clearDeploymentEnv/
5// requireNoRegistryTouch/loginTo/captureStdout, an httptest server standing
6// in for the console.
7package main
8
9import (
10 "encoding/json"
11 "net/http"
12 "net/http/httptest"
13 "reflect"
14 "strings"
15 "sync/atomic"
16 "testing"
17)
18
19// TestPipelineShowVerbAPIMode pins `carlos pipeline show`'s read path: it
20// GETs the pipeline and the pending list, and renders a multi-channel
21// pipeline as a table naming each channel's order, bake and rules, with any
22// pending change listed beneath.
23func TestPipelineShowVerbAPIMode(t *testing.T) {
24 useTempCredentials(t)
25 clearDeploymentEnv(t)
26 checkNoRegistry := requireNoRegistryTouch(t)
27
28 var gotPipelinePath, gotPendingPath string
29 mux := http.NewServeMux()
30 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
31 gotPipelinePath = r.URL.Path
32 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
33 "channels": []map[string]any{
34 {"name": "edge"},
35 {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
36 },
37 }})
38 })
39 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) {
40 gotPendingPath = r.URL.Path
41 json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{
42 {"id": "pc1", "kind": "policy", "summary": "change the pipeline to edge → production", "confirmed": 0, "needed": 1},
43 }})
44 })
45 ts := httptest.NewServer(mux)
46 defer ts.Close()
47
48 loginTo(t, ts.URL, "vicky@keymail.dev")
49
50 buf := captureStdout(t)
51 runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount})
52
53 out := buf.String()
54 if gotPipelinePath != "/api/cli/apps/"+apiTestAccount+"/titogo/pipeline" {
55 t.Fatalf("pipeline GET path = %q", gotPipelinePath)
56 }
57 if gotPendingPath != "/api/cli/apps/"+apiTestAccount+"/titogo/pending" {
58 t.Fatalf("pending GET path = %q", gotPendingPath)
59 }
60 if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
61 t.Fatalf("stdout = %q, want it to contain %q", out, want)
62 }
63 edgeAt := strings.Index(out, "edge")
64 prodAt := strings.Index(out, "production")
65 if edgeAt < 0 || prodAt < 0 || edgeAt > prodAt {
66 t.Fatalf("stdout = %q, want edge listed before production", out)
67 }
68 if !strings.Contains(out, "24h") {
69 t.Fatalf("stdout = %q, want the bake window", out)
70 }
71 if !strings.Contains(out, "passkey") || !strings.Contains(out, "change:1") {
72 t.Fatalf("stdout = %q, want the rules rendered", out)
73 }
74 if !strings.Contains(out, "change the pipeline to edge → production") || !strings.Contains(out, "0/1") || !strings.Contains(out, "pc1") {
75 t.Fatalf("stdout = %q, want the pending change listed with its progress and id", out)
76 }
77 checkNoRegistry()
78}
79
80// TestPipelineShowOneChannelNoLadderCeremony pins controller ruling 5: a
81// one-channel pipeline (here, the "no pipeline.json" default) prints a
82// single line, not a one-row table, with a hint that init grows it.
83func TestPipelineShowOneChannelNoLadderCeremony(t *testing.T) {
84 useTempCredentials(t)
85 clearDeploymentEnv(t)
86 checkNoRegistry := requireNoRegistryTouch(t)
87
88 mux := http.NewServeMux()
89 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
90 json.NewEncoder(w).Encode(map[string]any{"pipeline": nil})
91 })
92 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pending", func(w http.ResponseWriter, r *http.Request) {
93 json.NewEncoder(w).Encode(map[string]any{"pending": []map[string]any{}})
94 })
95 ts := httptest.NewServer(mux)
96 defer ts.Close()
97
98 loginTo(t, ts.URL, "vicky@keymail.dev")
99
100 buf := captureStdout(t)
101 runPipeline([]string{"show", "-app", "titogo", "-account", apiTestAccount})
102
103 out := buf.String()
104 if !strings.Contains(out, "edge") {
105 t.Fatalf("stdout = %q, want the single channel named", out)
106 }
107 if !strings.Contains(out, "carlos pipeline init") {
108 t.Fatalf("stdout = %q, want the init hint", out)
109 }
110 if strings.Contains(out, "ORDER") || strings.Contains(out, "RULES") {
111 t.Fatalf("stdout = %q, must not render ladder-table ceremony for one channel", out)
112 }
113 checkNoRegistry()
114}
115
116// TestPipelineInitTemplateAPIMode pins the edge-production template's exact
117// wire shape (controller ruling 3): a template that sets a rule also sets
118// change_approvals, and promote_approvals (never set by either template) is
119// omitted entirely rather than sent as 0.
120func TestPipelineInitTemplateAPIMode(t *testing.T) {
121 useTempCredentials(t)
122 clearDeploymentEnv(t)
123 checkNoRegistry := requireNoRegistryTouch(t)
124
125 var reqCount int32
126 var gotBody []byte
127 mux := http.NewServeMux()
128 mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
129 atomic.AddInt32(&reqCount, 1)
130 var buf [4096]byte
131 n, _ := r.Body.Read(buf[:])
132 gotBody = buf[:n]
133 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
134 "channels": []map[string]any{
135 {"name": "edge"},
136 {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
137 },
138 }})
139 })
140 ts := httptest.NewServer(mux)
141 defer ts.Close()
142
143 loginTo(t, ts.URL, "vicky@keymail.dev")
144
145 buf := captureStdout(t)
146 runPipeline([]string{"init", "-template", "edge-production", "-app", "titogo", "-account", apiTestAccount})
147
148 if got := atomic.LoadInt32(&reqCount); got != 1 {
149 t.Fatalf("server got %d PUT requests, want 1", got)
150 }
151
152 var sent struct {
153 Channels []map[string]any `json:"channels"`
154 }
155 if err := json.Unmarshal(gotBody, &sent); err != nil {
156 t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
157 }
158 want := []map[string]any{
159 {"name": "edge"},
160 {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}},
161 }
162 if !reflect.DeepEqual(sent.Channels, want) {
163 t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
164 }
165 if !strings.Contains(buf.String(), "titogo") {
166 t.Fatalf("stdout = %q, want it to name the app", buf.String())
167 }
168 checkNoRegistry()
169}
170
171// TestPipelineFullLadderTemplateAPIMode pins the second template's exact
172// shape alongside the first, per controller ruling 3.
173func TestPipelineFullLadderTemplateAPIMode(t *testing.T) {
174 useTempCredentials(t)
175 clearDeploymentEnv(t)
176 checkNoRegistry := requireNoRegistryTouch(t)
177
178 var gotBody []byte
179 mux := http.NewServeMux()
180 mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
181 var buf [4096]byte
182 n, _ := r.Body.Read(buf[:])
183 gotBody = buf[:n]
184 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
185 })
186 ts := httptest.NewServer(mux)
187 defer ts.Close()
188
189 loginTo(t, ts.URL, "vicky@keymail.dev")
190
191 captureStdout(t)
192 runPipeline([]string{"init", "-template", "full-ladder", "-app", "titogo", "-account", apiTestAccount})
193
194 var sent struct {
195 Channels []map[string]any `json:"channels"`
196 }
197 if err := json.Unmarshal(gotBody, &sent); err != nil {
198 t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
199 }
200 want := []map[string]any{
201 {"name": "edge"},
202 {"name": "beta", "bake": "24h"},
203 {"name": "stable", "bake": "72h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}},
204 }
205 if !reflect.DeepEqual(sent.Channels, want) {
206 t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
207 }
208 checkNoRegistry()
209}
210
211// TestPipelineSetComposesFullList pins set's compose-then-PUT contract: it
212// GETs the current list, mutates ONLY the named channel's ONE named flag
213// (-bake), and PUTs every channel back — including the untouched one
214// verbatim, and the edited one's OTHER rules (passkey, change_approvals)
215// preserved rather than clobbered by the flags this invocation didn't name.
216func TestPipelineSetComposesFullList(t *testing.T) {
217 useTempCredentials(t)
218 clearDeploymentEnv(t)
219 checkNoRegistry := requireNoRegistryTouch(t)
220
221 var gotBody []byte
222 var getCount, putCount int32
223 mux := http.NewServeMux()
224 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
225 atomic.AddInt32(&getCount, 1)
226 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
227 "channels": []map[string]any{
228 {"name": "edge"},
229 {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
230 },
231 }})
232 })
233 mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
234 atomic.AddInt32(&putCount, 1)
235 var buf [4096]byte
236 n, _ := r.Body.Read(buf[:])
237 gotBody = buf[:n]
238 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
239 })
240 ts := httptest.NewServer(mux)
241 defer ts.Close()
242
243 loginTo(t, ts.URL, "vicky@keymail.dev")
244
245 captureStdout(t)
246 runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount})
247
248 if got := atomic.LoadInt32(&getCount); got != 1 {
249 t.Fatalf("server got %d GET requests, want 1", got)
250 }
251 if got := atomic.LoadInt32(&putCount); got != 1 {
252 t.Fatalf("server got %d PUT requests, want 1", got)
253 }
254
255 var sent struct {
256 Channels []map[string]any `json:"channels"`
257 }
258 if err := json.Unmarshal(gotBody, &sent); err != nil {
259 t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
260 }
261 want := []map[string]any{
262 {"name": "edge"},
263 {"name": "production", "bake": "48h", "rules": map[string]any{"passkey": true, "change_approvals": float64(1)}},
264 }
265 if !reflect.DeepEqual(sent.Channels, want) {
266 t.Fatalf("PUT channels = %#v, want %#v (bake changed, everything else preserved)", sent.Channels, want)
267 }
268 checkNoRegistry()
269}
270
271// TestPipelineAddInsertsAfter pins add's -after insertion and its own
272// compose-then-PUT contract: it GETs the current list and PUTs it back with
273// the new channel spliced in immediately after the named one, the existing
274// channels otherwise untouched.
275func TestPipelineAddInsertsAfter(t *testing.T) {
276 useTempCredentials(t)
277 clearDeploymentEnv(t)
278 checkNoRegistry := requireNoRegistryTouch(t)
279
280 var gotBody []byte
281 mux := http.NewServeMux()
282 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
283 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
284 "channels": []map[string]any{{"name": "edge"}, {"name": "stable", "bake": "72h"}},
285 }})
286 })
287 mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
288 var buf [4096]byte
289 n, _ := r.Body.Read(buf[:])
290 gotBody = buf[:n]
291 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
292 })
293 ts := httptest.NewServer(mux)
294 defer ts.Close()
295
296 loginTo(t, ts.URL, "vicky@keymail.dev")
297
298 captureStdout(t)
299 runPipeline([]string{"add", "-channel", "beta", "-after", "edge", "-bake", "24h",
300 "-app", "titogo", "-account", apiTestAccount})
301
302 var sent struct {
303 Channels []map[string]any `json:"channels"`
304 }
305 if err := json.Unmarshal(gotBody, &sent); err != nil {
306 t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
307 }
308 want := []map[string]any{
309 {"name": "edge"},
310 {"name": "beta", "bake": "24h"},
311 {"name": "stable", "bake": "72h"},
312 }
313 if !reflect.DeepEqual(sent.Channels, want) {
314 t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
315 }
316 checkNoRegistry()
317}
318
319// TestPipelineRemoveDropsChannel pins remove's compose-then-PUT contract:
320// it GETs the current list and PUTs it back without the named channel, the
321// rest untouched and in the same order.
322func TestPipelineRemoveDropsChannel(t *testing.T) {
323 useTempCredentials(t)
324 clearDeploymentEnv(t)
325 checkNoRegistry := requireNoRegistryTouch(t)
326
327 var gotBody []byte
328 mux := http.NewServeMux()
329 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
330 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
331 "channels": []map[string]any{{"name": "edge"}, {"name": "beta", "bake": "24h"}, {"name": "stable", "bake": "72h"}},
332 }})
333 })
334 mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
335 var buf [4096]byte
336 n, _ := r.Body.Read(buf[:])
337 gotBody = buf[:n]
338 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{"channels": []map[string]any{}}})
339 })
340 ts := httptest.NewServer(mux)
341 defer ts.Close()
342
343 loginTo(t, ts.URL, "vicky@keymail.dev")
344
345 captureStdout(t)
346 runPipeline([]string{"remove", "-channel", "beta", "-app", "titogo", "-account", apiTestAccount})
347
348 var sent struct {
349 Channels []map[string]any `json:"channels"`
350 }
351 if err := json.Unmarshal(gotBody, &sent); err != nil {
352 t.Fatalf("PUT body unreadable: %v (%s)", err, gotBody)
353 }
354 want := []map[string]any{
355 {"name": "edge"},
356 {"name": "stable", "bake": "72h"},
357 }
358 if !reflect.DeepEqual(sent.Channels, want) {
359 t.Fatalf("PUT channels = %#v, want %#v", sent.Channels, want)
360 }
361 checkNoRegistry()
362}
363
364// TestPipelinePutProposedPrintsConsoleURL pins a guarded pipeline PUT's own
365// 202 handling — the same proposed-response contract as promote/rollback,
366// exercised through `carlos pipeline set` this time.
367func TestPipelinePutProposedPrintsConsoleURL(t *testing.T) {
368 useTempCredentials(t)
369 clearDeploymentEnv(t)
370 checkNoRegistry := requireNoRegistryTouch(t)
371
372 consoleURL := ""
373 mux := http.NewServeMux()
374 mux.HandleFunc("GET /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
375 json.NewEncoder(w).Encode(map[string]any{"pipeline": map[string]any{
376 "channels": []map[string]any{
377 {"name": "edge"},
378 {"name": "production", "bake": "24h", "rules": map[string]any{"passkey": true, "change_approvals": 1}},
379 },
380 }})
381 })
382 mux.HandleFunc("PUT /api/cli/apps/"+apiTestAccount+"/titogo/pipeline", func(w http.ResponseWriter, r *http.Request) {
383 w.WriteHeader(http.StatusAccepted)
384 json.NewEncoder(w).Encode(map[string]any{
385 "result": "proposed", "pending_id": "pc-99", "needed": 1, "confirmed": 0,
386 "console_url": consoleURL,
387 })
388 })
389 ts := httptest.NewServer(mux)
390 defer ts.Close()
391 consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo"
392
393 loginTo(t, ts.URL, "vicky@keymail.dev")
394
395 buf := captureStdout(t)
396 runPipeline([]string{"set", "-channel", "production", "-bake", "48h", "-app", "titogo", "-account", apiTestAccount})
397
398 out := buf.String()
399 if !strings.Contains(out, "proposed") || !strings.Contains(out, consoleURL) || !strings.Contains(out, "pc-99") {
400 t.Fatalf("stdout = %q, want proposed + console URL + pending id", out)
401 }
402 checkNoRegistry()
403}
404
405// TestPromoteProposedPrintsConsoleURL pins runPromote's handling of a 202: a
406// guarded channel's PROMOTE_APPROVALS rule turns the request into a
407// proposal, and the CLI must say so — naming how many confirmations it
408// needs and where to finish — rather than reporting the generic "unexpected
409// response (status 202)" (the CLI-lies-about-its-outcome bug this task
410// exists to close).
411func TestPromoteProposedPrintsConsoleURL(t *testing.T) {
412 useTempCredentials(t)
413 clearDeploymentEnv(t)
414 checkNoRegistry := requireNoRegistryTouch(t)
415
416 consoleURL := ""
417 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
418 w.WriteHeader(http.StatusAccepted)
419 json.NewEncoder(w).Encode(map[string]any{
420 "result": "proposed", "pending_id": "pc-42", "needed": 2, "confirmed": 0,
421 "console_url": consoleURL,
422 })
423 }))
424 defer ts.Close()
425 consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo"
426
427 loginTo(t, ts.URL, "vicky@keymail.dev")
428
429 buf := captureStdout(t)
430 runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "production"})
431
432 out := buf.String()
433 if !strings.Contains(out, "proposed") {
434 t.Fatalf("stdout = %q, want it to say proposed", out)
435 }
436 if !strings.Contains(out, "2") {
437 t.Fatalf("stdout = %q, want the confirmations-needed count (2)", out)
438 }
439 if !strings.Contains(out, consoleURL) {
440 t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL)
441 }
442 checkNoRegistry()
443}
444
445// TestRollbackProposedPrintsConsoleURL is TestPromoteProposedPrintsConsoleURL's
446// twin for `carlos rollback` (controller ruling 1): Task 8 added the 202 on
447// the server side, but runRollback still reported the generic "unexpected
448// response (status 202)" until this task extended it exactly as promote was
449// extended.
450func TestRollbackProposedPrintsConsoleURL(t *testing.T) {
451 useTempCredentials(t)
452 clearDeploymentEnv(t)
453 checkNoRegistry := requireNoRegistryTouch(t)
454
455 consoleURL := ""
456 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
457 w.WriteHeader(http.StatusAccepted)
458 json.NewEncoder(w).Encode(map[string]any{
459 "result": "proposed", "pending_id": "pc-43", "needed": 1, "confirmed": 0,
460 "console_url": consoleURL,
461 })
462 }))
463 defer ts.Close()
464 consoleURL = ts.URL + "/a/" + apiTestAccount + "/apps/titogo"
465
466 loginTo(t, ts.URL, "vicky@keymail.dev")
467
468 buf := captureStdout(t)
469 runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "production"})
470
471 out := buf.String()
472 if !strings.Contains(out, "proposed") {
473 t.Fatalf("stdout = %q, want it to say proposed", out)
474 }
475 if !strings.Contains(out, "1") {
476 t.Fatalf("stdout = %q, want the confirmations-needed count (1)", out)
477 }
478 if !strings.Contains(out, consoleURL) {
479 t.Fatalf("stdout = %q, want the console URL %q", out, consoleURL)
480 }
481 if !strings.Contains(out, "pc-43") {
482 t.Fatalf("stdout = %q, want the pending id", out)
483 }
484 checkNoRegistry()
485}
486
487// TestPromoteHotfixSentToAPI pins controller ruling 2: -hotfix is no longer
488// refused in API mode (cli.go used to log.Fatal before ever dialing out,
489// claiming the console "has no hotfix field at all" — now false), and the
490// field actually reaches the wire.
491func TestPromoteHotfixSentToAPI(t *testing.T) {
492 useTempCredentials(t)
493 clearDeploymentEnv(t)
494 checkNoRegistry := requireNoRegistryTouch(t)
495
496 var reqCount int32
497 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
498 atomic.AddInt32(&reqCount, 1)
499 var req struct {
500 Version string `json:"version"`
501 Channel string `json:"channel"`
502 Hotfix bool `json:"hotfix"`
503 }
504 json.NewDecoder(r.Body).Decode(&req)
505 if !req.Hotfix {
506 t.Errorf("hotfix = %v, want true", req.Hotfix)
507 }
508 if req.Version != "abc1234" || req.Channel != "stable" {
509 t.Errorf("promote body = %+v", req)
510 }
511 json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""})
512 }))
513 defer ts.Close()
514
515 loginTo(t, ts.URL, "vicky@keymail.dev")
516
517 buf := captureStdout(t)
518 runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "-hotfix", "abc1234", "stable"})
519
520 if got := atomic.LoadInt32(&reqCount); got != 1 {
521 t.Fatalf("server got %d requests, want 1", got)
522 }
523 if want := "promoted titogo abc1234 -> stable"; !strings.Contains(buf.String(), want) {
524 t.Fatalf("stdout = %q, want %q", buf.String(), want)
525 }
526 checkNoRegistry()
527}
528
529// TestPipelineDirectModeRefused pins the direct/bucket-mode refusal: there
530// is no bucket-side pipeline editor, so a `carlos pipeline` verb must name
531// the console requirement rather than silently doing nothing or panicking
532// on a nil session. Exercised at the resolveMode level, mirroring how
533// runPromote's stable refusal is pinned in auth_e2e_test.go — runPipeline
534// itself calls log.Fatal (os.Exit) on this path, which an in-process test
535// cannot survive.
536func TestPipelineDirectModeRefused(t *testing.T) {
537 dir := t.TempDir()
538 t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
539 t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
540 t.Setenv("CARLOS_CONSOLE", "")
541
542 sess, err := resolveMode("")
543 if err != nil {
544 t.Fatalf("resolveMode: %v", err)
545 }
546 if sess != nil {
547 t.Fatal("want direct mode (nil session) with CARLOS_DEPLOYMENT_DIR set")
548 }
549}
550