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)
16
17var uiAdoptionHelperCalls = map[string]struct{}{
18 "cliWriteLine": {},
19 "cliWritef": {},
20 "cliRenderTitle": {},
21 "cliRenderMuted": {},
22 "cliRenderEmptyState": {},
23 "cliRenderListItem": {},
24 "cliRenderMeta": {},
25 "cliRenderSuccess": {},
26 "cliRenderWarning": {},
27 "cliRenderInfo": {},
28}
29
30// uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and
31// cliWritef variables. Any test that overrides these seams must hold this
32// mutex for the duration of the override and restore the originals on cleanup.
33var uiAdoptionSeamMu sync.Mutex
34
35func TestPresentationHelpers(t *testing.T) {
36 if got := cliRenderTitle("Title"); got == "" {
37 t.Fatal("cliRenderTitle returned empty output")
38 }
39 if got := cliRenderEmptyState("none"); got == "" {
40 t.Fatal("cliRenderEmptyState returned empty output")
41 }
42 if got := cliRenderSuccess("ok"); got == "" {
43 t.Fatal("cliRenderSuccess returned empty output")
44 }
45 if got := cliRenderWarning("warn"); got == "" {
46 t.Fatal("cliRenderWarning returned empty output")
47 }
48 if got := cliRenderInfo("info"); got == "" {
49 t.Fatal("cliRenderInfo returned empty output")
50 }
51 if got := cliRenderListItem("item"); got == "" {
52 t.Fatal("cliRenderListItem returned empty output")
53 }
54}
55
56func TestUIAdoption(t *testing.T) {
57 for _, expect := range uiAdoptionExpectations {
58 expect := expect
59 t.Run(expect.family, func(t *testing.T) {
60 fset := token.NewFileSet()
61 fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
62 if err != nil {
63 t.Fatalf("failed to parse %s: %v", expect.file, err)
64 }
65
66 for _, fnName := range expect.functions {
67 fnName := fnName
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
153type imagesClientStub struct {
154 listResp []dto.Image
155 pruneResp *dto.ImagePruneResponse
156 err error
157}
158
159func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) {
160 if s.err != nil {
161 return nil, s.err
162 }
163 return s.listResp, nil
164}
165
166func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
167 if s.err != nil {
168 return nil, s.err
169 }
170 return s.pruneResp, nil
171}
172
173func TestUIAdoptionRuntimeSeams(t *testing.T) {
174 uiAdoptionSeamMu.Lock()
175 defer uiAdoptionSeamMu.Unlock()
176
177 origWriteLine := cliWriteLine
178 origWritef := cliWritef
179 defer func() {
180 cliWriteLine = origWriteLine
181 cliWritef = origWritef
182 }()
183
184 lineCalls := 0
185 writefCalls := 0
186 cliWriteLine = func(w io.Writer, msg string) error {
187 lineCalls++
188 _, err := io.WriteString(w, msg+"\n")
189 return err
190 }
191 cliWritef = func(w io.Writer, format string, args ...any) error {
192 writefCalls++
193 _, err := io.WriteString(w, "formatted\n")
194 return err
195 }
196
197 versionCmd := newVersionCmd()
198 versionCmd.SetArgs(nil)
199 versionCmd.SetOut(new(bytes.Buffer))
200 if err := versionCmd.Execute(); err != nil {
201 t.Fatalf("version command execution failed: %v", err)
202 }
203
204 imgBuf := new(bytes.Buffer)
205 if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf, false); err != nil {
206 t.Fatalf("runImagesList failed: %v", err)
207 }
208
209 pruneBuf := new(bytes.Buffer)
210 if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
211 t.Fatalf("runImagesPrune failed: %v", err)
212 }
213
214 if lineCalls == 0 {
215 t.Fatal("expected cliWriteLine to be called")
216 }
217 if writefCalls == 0 {
218 t.Fatal("expected cliWritef to be called")
219 }
220}
221
222func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
223 for _, decl := range file.Decls {
224 fn, ok := decl.(*ast.FuncDecl)
225 if !ok {
226 continue
227 }
228 if fn.Name.Name == name {
229 return fn
230 }
231 }
232 return nil
233}