1package cli
2
3import (
4 "bytes"
5 "context"
6 "go/ast"
7 "go/parser"
8 "go/token"
9 "io"
10 "strings"
11 "sync"
12 "testing"
13
14 "github.com/bnema/gordon/internal/adapters/dto"
15 climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
16)
17
18var uiAdoptionHelperCalls = map[string]struct{}{
19 "cliWriteLine": {},
20 "cliWritef": {},
21 "cliRenderTitle": {},
22 "cliRenderMuted": {},
23 "cliRenderEmptyState": {},
24 "cliRenderListItem": {},
25 "cliRenderMeta": {},
26 "cliRenderSuccess": {},
27 "cliRenderWarning": {},
28 "cliRenderInfo": {},
29}
30
31// uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and
32// cliWritef variables. Any test that overrides these seams must hold this
33// mutex for the duration of the override and restore the originals on cleanup.
34var uiAdoptionSeamMu sync.Mutex
35
36func TestPresentationHelpers(t *testing.T) {
37 if got := cliRenderTitle("Title"); got == "" {
38 t.Fatal("cliRenderTitle returned empty output")
39 }
40 if got := cliRenderEmptyState("none"); got == "" {
41 t.Fatal("cliRenderEmptyState returned empty output")
42 }
43 if got := cliRenderSuccess("ok"); got == "" {
44 t.Fatal("cliRenderSuccess returned empty output")
45 }
46 if got := cliRenderWarning("warn"); got == "" {
47 t.Fatal("cliRenderWarning returned empty output")
48 }
49 if got := cliRenderInfo("info"); got == "" {
50 t.Fatal("cliRenderInfo returned empty output")
51 }
52 if got := cliRenderListItem("item"); got == "" {
53 t.Fatal("cliRenderListItem returned empty output")
54 }
55}
56
57func TestUIAdoption(t *testing.T) {
58 for i := range uiAdoptionExpectations {
59 expect := uiAdoptionExpectations[i]
60 t.Run(expect.family, func(t *testing.T) {
61 fset := token.NewFileSet()
62 fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
63 if err != nil {
64 t.Fatalf("failed to parse %s: %v", expect.file, err)
65 }
66
67 for _, fnName := range expect.functions {
68 t.Run(fnName, func(t *testing.T) {
69 fn := findFuncDecl(fileNode, fnName)
70 if fn == nil {
71 t.Fatalf("function %s not found in %s", fnName, expect.file)
72 }
73
74 hasHelperCall := false
75 hasSharedUIUsage := false
76 hasForbiddenRawPrint := false
77
78 ast.Inspect(fn.Body, func(n ast.Node) bool {
79 call, ok := n.(*ast.CallExpr)
80 if !ok {
81 return true
82 }
83
84 switch fun := call.Fun.(type) {
85 case *ast.Ident:
86 if _, ok := uiAdoptionHelperCalls[fun.Name]; ok {
87 hasHelperCall = true
88 }
89 case *ast.SelectorExpr:
90 if isForbiddenRawPrintCall(call, fun) {
91 hasForbiddenRawPrint = true
92 }
93 pkgIdent, ok := fun.X.(*ast.Ident)
94 if !ok {
95 return true
96 }
97 if pkgIdent.Name == "styles" || pkgIdent.Name == "components" {
98 hasSharedUIUsage = true
99 }
100 }
101
102 return true
103 })
104
105 if !hasHelperCall && !hasSharedUIUsage {
106 t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file)
107 }
108
109 if hasForbiddenRawPrint {
110 t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file)
111 }
112 })
113 }
114 })
115 }
116}
117
118func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool {
119 pkgIdent, ok := sel.X.(*ast.Ident)
120 if !ok {
121 return false
122 }
123
124 if pkgIdent.Name == "fmt" {
125 switch sel.Sel.Name {
126 case "Print", "Printf", "Println":
127 return true
128 case "Fprint", "Fprintf", "Fprintln":
129 if len(call.Args) == 0 {
130 return true
131 }
132
133 switch dst := call.Args[0].(type) {
134 case *ast.SelectorExpr:
135 if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") {
136 return true
137 }
138 case *ast.Ident:
139 if dst.Name == "out" {
140 return true
141 }
142 }
143 }
144 }
145
146 if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") {
147 return true
148 }
149
150 return false
151}
152
153func TestUIAdoptionRuntimeSeams(t *testing.T) {
154 uiAdoptionSeamMu.Lock()
155 defer uiAdoptionSeamMu.Unlock()
156
157 origWriteLine := cliWriteLine
158 origWritef := cliWritef
159 defer func() {
160 cliWriteLine = origWriteLine
161 cliWritef = origWritef
162 }()
163
164 lineCalls := 0
165 writefCalls := 0
166 cliWriteLine = func(w io.Writer, msg string) error {
167 lineCalls++
168 _, err := io.WriteString(w, msg+"\n")
169 return err
170 }
171 cliWritef = func(w io.Writer, format string, args ...any) error {
172 writefCalls++
173 _, err := io.WriteString(w, "formatted\n")
174 return err
175 }
176
177 versionCmd := newVersionCmd()
178 versionCmd.SetArgs(nil)
179 versionCmd.SetOut(new(bytes.Buffer))
180 if err := versionCmd.Execute(); err != nil {
181 t.Fatalf("version command execution failed: %v", err)
182 }
183
184 imagesMock := climocks.NewMockimagesClient(t)
185 imagesMock.EXPECT().ListImages(context.Background()).Return([]dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}, nil).Once()
186
187 imgBuf := new(bytes.Buffer)
188 if err := runImagesList(context.Background(), imagesMock, imgBuf, false); err != nil {
189 t.Fatalf("runImagesList failed: %v", err)
190 }
191
192 pruneMock := climocks.NewMockimagesClient(t)
193 pruneMock.EXPECT().ListImages(context.Background()).Return([]dto.Image{}, nil).Once()
194
195 pruneBuf := new(bytes.Buffer)
196 if err := runImagesPrune(context.Background(), pruneMock, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
197 t.Fatalf("runImagesPrune failed: %v", err)
198 }
199
200 if lineCalls == 0 {
201 t.Fatal("expected cliWriteLine to be called")
202 }
203 if writefCalls == 0 {
204 t.Fatal("expected cliWritef to be called")
205 }
206}
207
208func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
209 for _, decl := range file.Decls {
210 fn, ok := decl.(*ast.FuncDecl)
211 if !ok {
212 continue
213 }
214 if fn.Name.Name == name {
215 return fn
216 }
217 }
218 return nil
219}