1package cli
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "regexp"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/adapters/dto"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19type imagesClientMock struct {
20 listImagesResp []dto.Image
21 listImagesErr error
22 pruneResp *dto.ImagePruneResponse
23 pruneErr error
24
25 listImagesCalls int
26 pruneCalls int
27 lastPruneOpts dto.ImagePruneRequest
28}
29
30func (f *imagesClientMock) ListImages(_ context.Context) ([]dto.Image, error) {
31 f.listImagesCalls++
32 if f.listImagesErr != nil {
33 return nil, f.listImagesErr
34 }
35 return f.listImagesResp, nil
36}
37
38func (f *imagesClientMock) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
39 f.pruneCalls++
40 f.lastPruneOpts = req
41 if f.pruneErr != nil {
42 return nil, f.pruneErr
43 }
44 return f.pruneResp, nil
45}
46
47// ---------------------------------------------------------------------------
48// images list tests (unchanged)
49// ---------------------------------------------------------------------------
50
51func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
52 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
53 client := &imagesClientMock{
54 listImagesResp: []dto.Image{
55 {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
56 {Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true},
57 },
58 }
59
60 var out bytes.Buffer
61 err := runImagesList(context.Background(), client, &out, false)
62 require.NoError(t, err)
63
64 text := out.String()
65 assert.Contains(t, text, "REPOSITORY")
66 assert.Contains(t, text, "registry.example.com/app")
67 assert.Contains(t, text, "latest")
68 assert.Contains(t, text, "Total images: 2 (dangling: 1)")
69 assert.Equal(t, 1, client.listImagesCalls)
70}
71
72func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
73 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
74 client := &imagesClientMock{
75 listImagesResp: []dto.Image{
76 {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
77 },
78 }
79
80 var out bytes.Buffer
81 err := runImagesList(context.Background(), client, &out, false)
82 require.NoError(t, err)
83
84 text := stripANSI(out.String())
85 header := findHeaderLine(text)
86 assert.NotEmpty(t, header)
87 assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
88 assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
89 assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
90 assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
91 assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
92 assert.Contains(t, text, "Total images: 1 (dangling: 0)")
93}
94
95func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) {
96 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
97 longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use"
98 longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow"
99 client := &imagesClientMock{
100 listImagesResp: []dto.Image{
101 {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false},
102 },
103 }
104
105 var out bytes.Buffer
106 err := runImagesList(context.Background(), client, &out, false)
107 require.NoError(t, err)
108
109 text := stripANSI(out.String())
110 header := findHeaderLine(text)
111 assert.NotEmpty(t, header)
112 assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
113 assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
114 assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
115 assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
116 assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
117
118 row := findImageListRowLine(text, "latest")
119 assert.NotEmpty(t, row)
120
121 cols := splitTableColumns(row)
122 require.Len(t, cols, 6)
123
124 repositoryField := cols[0]
125 imageIDField := cols[4]
126
127 assert.NotEqual(t, longRepository, repositoryField)
128 assert.NotEqual(t, longImageID, imageIDField)
129 assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
130 assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
131 assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
132 assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
133
134 assert.Contains(t, text, "Total images: 1 (dangling: 0)")
135}
136
137func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) {
138 longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra"
139 longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name"
140 longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
141 client := &imagesClientMock{
142 listImagesResp: []dto.Image{
143 {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true},
144 },
145 }
146
147 var out bytes.Buffer
148 err := runImagesList(context.Background(), client, &out, false)
149 require.NoError(t, err)
150
151 text := stripANSI(out.String())
152 rows := findImageListRows(text)
153 require.Len(t, rows, 1, "expected a single, no-wrap data row")
154
155 cols := rows[0]
156 require.Len(t, cols, 6)
157
158 repositoryField := cols[0]
159 tagField := cols[1]
160 sizeField := cols[2]
161 createdField := cols[3]
162 imageIDField := cols[4]
163
164 assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
165 assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis")
166 assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
167 assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
168 assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169 assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170
171 assert.Equal(t, "0 B", sizeField)
172 assert.Equal(t, "-", createdField)
173 assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174}
175
176func TestRunImagesList_EmptyOutput(t *testing.T) {
177 client := &imagesClientMock{listImagesResp: []dto.Image{}}
178
179 var out bytes.Buffer
180 err := runImagesList(context.Background(), client, &out, false)
181 require.NoError(t, err)
182
183 text := out.String()
184 assert.Contains(t, text, "No images found")
185 assert.Contains(t, text, "Total images: 0")
186 assert.Equal(t, 1, client.listImagesCalls)
187}
188
189// ---------------------------------------------------------------------------
190// images prune: default scope behavior
191// ---------------------------------------------------------------------------
192
193func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) {
194 // No scope flags → both scopes enabled, keep-releases defaults to domain default.
195 client := &imagesClientMock{
196 pruneResp: &dto.ImagePruneResponse{
197 Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512},
198 Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024},
199 },
200 }
201
202 var out bytes.Buffer
203 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
204 err := runImagesPrune(context.Background(), client, opts, &out)
205 require.NoError(t, err)
206
207 assert.Equal(t, 1, client.pruneCalls)
208 require.NotNil(t, client.lastPruneOpts.KeepLast)
209 assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast)
210 require.NotNil(t, client.lastPruneOpts.PruneDangling)
211 assert.True(t, *client.lastPruneOpts.PruneDangling)
212 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
213 assert.True(t, *client.lastPruneOpts.PruneRegistry)
214
215 text := out.String()
216 assert.Contains(t, text, "Runtime: deleted=1")
217 assert.Contains(t, text, "Registry: tags_removed=2")
218}
219
220func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) {
221 client := &imagesClientMock{
222 pruneResp: &dto.ImagePruneResponse{
223 Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048},
224 },
225 }
226
227 var out bytes.Buffer
228 opts := imagesPruneOptions{Dangling: true, NoConfirm: true}
229 err := runImagesPrune(context.Background(), client, opts, &out)
230 require.NoError(t, err)
231
232 assert.Equal(t, 1, client.pruneCalls)
233 require.NotNil(t, client.lastPruneOpts.PruneDangling)
234 assert.True(t, *client.lastPruneOpts.PruneDangling)
235 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
236 assert.False(t, *client.lastPruneOpts.PruneRegistry)
237
238 text := out.String()
239 assert.Contains(t, text, "Runtime: deleted=3")
240 assert.Contains(t, text, "Registry cleanup skipped")
241}
242
243func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) {
244 client := &imagesClientMock{
245 pruneResp: &dto.ImagePruneResponse{
246 Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096},
247 },
248 }
249
250 var out bytes.Buffer
251 opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true}
252 err := runImagesPrune(context.Background(), client, opts, &out)
253 require.NoError(t, err)
254
255 assert.Equal(t, 1, client.pruneCalls)
256 require.NotNil(t, client.lastPruneOpts.PruneDangling)
257 assert.False(t, *client.lastPruneOpts.PruneDangling)
258 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
259 assert.True(t, *client.lastPruneOpts.PruneRegistry)
260 require.NotNil(t, client.lastPruneOpts.KeepLast)
261 assert.Equal(t, 5, *client.lastPruneOpts.KeepLast)
262
263 text := out.String()
264 assert.Contains(t, text, "Runtime cleanup skipped")
265 assert.Contains(t, text, "Registry: tags_removed=4")
266}
267
268func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
269 client := &imagesClientMock{
270 pruneResp: &dto.ImagePruneResponse{
271 Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100},
272 Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200},
273 },
274 }
275
276 var out bytes.Buffer
277 opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true}
278 err := runImagesPrune(context.Background(), client, opts, &out)
279 require.NoError(t, err)
280
281 assert.Equal(t, 1, client.pruneCalls)
282 require.NotNil(t, client.lastPruneOpts.PruneDangling)
283 assert.True(t, *client.lastPruneOpts.PruneDangling)
284 require.NotNil(t, client.lastPruneOpts.PruneRegistry)
285 assert.True(t, *client.lastPruneOpts.PruneRegistry)
286 require.NotNil(t, client.lastPruneOpts.KeepLast)
287 assert.Equal(t, 2, *client.lastPruneOpts.KeepLast)
288
289 text := out.String()
290 assert.Contains(t, text, "Runtime: deleted=1")
291 assert.Contains(t, text, "Registry: tags_removed=1")
292}
293
294// ---------------------------------------------------------------------------
295// images prune: dry-run with scope flags
296// ---------------------------------------------------------------------------
297
298func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299 client := &imagesClientMock{
300 pruneResp: &dto.ImagePruneResponse{
301 Plan: dto.PruneSummary{
302 Applied: false,
303 Eligible: 2,
304 Protected: 1,
305 Candidates: []dto.PruneCandidate{
306 {Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"},
307 {Kind: "runtime-image", Ref: "sha256:b", Verdict: "eligible"},
308 {Kind: "registry-tag", Ref: "app:v1", Verdict: "protected", Reasons: []string{"protected-active-service"}},
309 },
310 },
311 },
312 }
313
314 var out bytes.Buffer
315 opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
316 err := runImagesPrune(context.Background(), client, opts, &out)
317 require.NoError(t, err)
318
319 text := out.String()
320 assert.Contains(t, text, "Dry run")
321 assert.Contains(t, text, "would delete 2 images")
322 assert.Contains(t, text, "latest + 3 previous tags")
323 assert.Contains(t, text, "Would delete: 2 (eligible=2 protected=1 unknown=0)")
324 assert.Contains(t, text, "protected-active-service")
325 assert.Equal(t, 1, client.pruneCalls)
326 assert.NotNil(t, client.lastPruneOpts.DryRun)
327 assert.True(t, *client.lastPruneOpts.DryRun)
328}
329
330func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
331 client := &imagesClientMock{
332 pruneResp: &dto.ImagePruneResponse{
333 Plan: dto.PruneSummary{
334 Candidates: []dto.PruneCandidate{{Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"}},
335 },
336 },
337 }
338
339 var out bytes.Buffer
340 opts := imagesPruneOptions{DryRun: true, Dangling: true}
341 err := runImagesPrune(context.Background(), client, opts, &out)
342 require.NoError(t, err)
343
344 text := out.String()
345 assert.Contains(t, text, "would delete 1 images")
346 assert.Contains(t, text, "Registry cleanup skipped")
347 assert.Equal(t, 1, client.pruneCalls)
348}
349
350func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
351 client := &imagesClientMock{
352 pruneResp: &dto.ImagePruneResponse{
353 Plan: dto.PruneSummary{
354 Candidates: []dto.PruneCandidate{{Kind: "registry-tag", Ref: "app:v1", Verdict: "eligible"}},
355 },
356 },
357 }
358
359 var out bytes.Buffer
360 opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5}
361 err := runImagesPrune(context.Background(), client, opts, &out)
362 require.NoError(t, err)
363
364 text := out.String()
365 assert.Contains(t, text, "Runtime cleanup skipped")
366 assert.Contains(t, text, "latest + 5 previous tags")
367 assert.Equal(t, 1, client.pruneCalls)
368}
369
370// ---------------------------------------------------------------------------
371// images prune: keep-releases semantics
372// ---------------------------------------------------------------------------
373
374func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) {
375 client := &imagesClientMock{
376 pruneResp: &dto.ImagePruneResponse{
377 Runtime: dto.RuntimePruneResult{DeletedCount: 0},
378 Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000},
379 },
380 }
381
382 var out bytes.Buffer
383 opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true}
384 err := runImagesPrune(context.Background(), client, opts, &out)
385 require.NoError(t, err)
386
387 require.NotNil(t, client.lastPruneOpts.KeepLast)
388 assert.Equal(t, 10, *client.lastPruneOpts.KeepLast)
389}
390
391func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) {
392 err := runImagesPrune(context.Background(), &imagesClientMock{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{})
393 require.Error(t, err)
394 assert.Contains(t, err.Error(), "--keep-releases must be >= 0")
395}
396
397// ---------------------------------------------------------------------------
398// images prune: confirmation behavior
399// ---------------------------------------------------------------------------
400
401func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
402 confirmCalled := false
403 origConfirm := pruneConfirmFunc
404 pruneConfirmFunc = func(_ string) (bool, error) {
405 confirmCalled = true
406 return true, nil
407 }
408 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
409
410 client := &imagesClientMock{
411 pruneResp: &dto.ImagePruneResponse{
412 Runtime: dto.RuntimePruneResult{DeletedCount: 1},
413 Registry: dto.RegistryPruneResult{TagsRemoved: 1},
414 },
415 }
416
417 var out bytes.Buffer
418 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
419 err := runImagesPrune(context.Background(), client, opts, &out)
420 require.NoError(t, err)
421
422 assert.True(t, confirmCalled, "confirmation should have been called")
423 assert.Equal(t, 1, client.pruneCalls)
424}
425
426func TestRunImagesPrune_ConfirmPromptIncludesScopePreviewCounts(t *testing.T) {
427 created := time.Date(2026, 2, 13, 7, 0, 0, 0, time.UTC)
428 var prompt string
429 origConfirm := pruneConfirmFunc
430 pruneConfirmFunc = func(value string) (bool, error) {
431 prompt = value
432 return false, nil
433 }
434 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
435
436 client := &imagesClientMock{
437 listImagesResp: []dto.Image{
438 {Repository: "registry.example.com/app", Tag: "latest", Created: created},
439 {Repository: "registry.example.com/app", Tag: "v3", Created: created.Add(-1 * time.Hour)},
440 {Repository: "registry.example.com/app", Tag: "v2", Created: created.Add(-2 * time.Hour)},
441 {Repository: "registry.example.com/app", Tag: "v1", Created: created.Add(-3 * time.Hour)},
442 {Repository: "<none>", Tag: "<none>", Dangling: true},
443 },
444 }
445
446 var out bytes.Buffer
447 opts := imagesPruneOptions{KeepReleases: 2}
448 err := runImagesPrune(context.Background(), client, opts, &out)
449 require.NoError(t, err)
450
451 assert.Contains(t, prompt, "Runtime: would prune 1 dangling runtime images")
452 assert.Contains(t, prompt, "Registry: would prune 1 tags")
453 assert.Equal(t, 1, client.listImagesCalls)
454 assert.Equal(t, 0, client.pruneCalls)
455}
456
457func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
458 origConfirm := pruneConfirmFunc
459 pruneConfirmFunc = func(_ string) (bool, error) {
460 return false, nil
461 }
462 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
463
464 client := &imagesClientMock{pruneResp: &dto.ImagePruneResponse{}}
465
466 var out bytes.Buffer
467 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
468 err := runImagesPrune(context.Background(), client, opts, &out)
469 require.NoError(t, err)
470
471 assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection")
472 assert.Contains(t, out.String(), "cancelled")
473}
474
475func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) {
476 confirmCalled := false
477 origConfirm := pruneConfirmFunc
478 pruneConfirmFunc = func(_ string) (bool, error) {
479 confirmCalled = true
480 return true, nil
481 }
482 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
483
484 client := &imagesClientMock{
485 pruneResp: &dto.ImagePruneResponse{
486 Runtime: dto.RuntimePruneResult{DeletedCount: 1},
487 },
488 }
489
490 var out bytes.Buffer
491 opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
492 err := runImagesPrune(context.Background(), client, opts, &out)
493 require.NoError(t, err)
494
495 assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm")
496 assert.Equal(t, 1, client.pruneCalls)
497}
498
499func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
500 confirmCalled := false
501 origConfirm := pruneConfirmFunc
502 pruneConfirmFunc = func(_ string) (bool, error) {
503 confirmCalled = true
504 return true, nil
505 }
506 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
507
508 client := &imagesClientMock{
509 pruneResp: &dto.ImagePruneResponse{Plan: dto.PruneSummary{}},
510 }
511
512 var out bytes.Buffer
513 opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
514 err := runImagesPrune(context.Background(), client, opts, &out)
515 require.NoError(t, err)
516
517 assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
518 assert.Equal(t, 1, client.pruneCalls)
519}
520
521// ---------------------------------------------------------------------------
522// images prune: error handling
523// ---------------------------------------------------------------------------
524
525func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
526 origConfirm := pruneConfirmFunc
527 pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
528 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
529
530 client := &imagesClientMock{pruneErr: errors.New("request failed")}
531
532 err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{})
533 require.Error(t, err)
534 assert.Contains(t, err.Error(), "failed to prune images")
535}
536
537func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
538 origConfirm := pruneConfirmFunc
539 pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
540 t.Cleanup(func() { pruneConfirmFunc = origConfirm })
541
542 client := &imagesClientMock{pruneResp: nil}
543 err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{})
544 require.Error(t, err)
545 assert.Contains(t, err.Error(), "empty response")
546}
547
548// ---------------------------------------------------------------------------
549// test helpers
550// ---------------------------------------------------------------------------
551
552func stripANSI(value string) string {
553 ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
554 return ansiRegex.ReplaceAllString(value, "")
555}
556
557func findHeaderLine(text string) string {
558 for line := range strings.SplitSeq(text, "\n") {
559 if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") {
560 return line
561 }
562 }
563
564 return ""
565}
566
567func findImageListRowLine(text string, tag string) string {
568 for line := range strings.SplitSeq(text, "\n") {
569 trimmed := strings.TrimSpace(line)
570 if trimmed == "" {
571 continue
572 }
573 if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
574 continue
575 }
576
577 cols := splitTableColumns(line)
578 if len(cols) == 6 && cols[1] == tag {
579 return line
580 }
581 }
582
583 return ""
584}
585
586func findImageListRows(text string) [][]string {
587 rows := make([][]string, 0)
588 for line := range strings.SplitSeq(text, "\n") {
589 trimmed := strings.TrimSpace(line)
590 if trimmed == "" {
591 continue
592 }
593 if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
594 continue
595 }
596
597 cols := splitTableColumns(line)
598 if len(cols) == 6 && cols[0] != "REPOSITORY" {
599 rows = append(rows, cols)
600 }
601 }
602
603 return rows
604}
605
606func splitTableColumns(line string) []string {
607 if strings.Contains(line, "│") {
608 parts := strings.Split(line, "│")
609 cols := make([]string, 0, len(parts))
610 for _, part := range parts {
611 cell := strings.TrimSpace(part)
612 if cell != "" {
613 cols = append(cols, cell)
614 }
615 }
616 return cols
617 }
618
619 return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1)
620}