blob: c3cc65c85b7d025dd8e102d03c0771dd3aebbe65 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package dockerimg
2
3import (
Josh Bleecher Snyder4d5e9972025-05-01 15:56:37 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
6 "flag"
7 "io/fs"
8 "net/http"
9 "os"
Josh Bleecher Snyder3e6a4c42025-05-23 17:29:57 +000010 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070011 "strings"
12 "testing"
13 "testing/fstest"
14
Josh Bleecher Snyder4d5e9972025-05-01 15:56:37 -070015 gcmp "github.com/google/go-cmp/cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "sketch.dev/httprr"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070017 "sketch.dev/llm/ant"
Earl Lee2e463fb2025-04-17 11:22:22 -070018)
19
20var flagRewriteWant = flag.Bool("rewritewant", false, "rewrite the dockerfiles we want from the model")
21
22func TestCreateDockerfile(t *testing.T) {
23 ctx := context.Background()
24
25 tests := []struct {
26 name string
27 fsys fs.FS
28 }{
29 {
30 name: "Basic repo with README",
31 fsys: fstest.MapFS{
32 "README.md": &fstest.MapFile{Data: []byte("# Test Project\nA Go project for testing.")},
33 },
34 },
35 {
36 // TODO: this looks bogus.
37 name: "Repo with README and workflow",
38 fsys: fstest.MapFS{
39 "README.md": &fstest.MapFile{Data: []byte("# Test Project\nA Go project for testing.")},
40 ".github/workflows/test.yml": &fstest.MapFile{Data: []byte(`name: Test
41on: [push]
42jobs:
43 test:
44 runs-on: ubuntu-latest
45 steps:
46 - uses: actions/checkout@v2
47 - uses: actions/setup-node@v3
48 with:
49 node-version: '18'
50 - name: Install and activate corepack
51 run: |
52 npm install -g corepack
53 corepack enable
54 - run: go test ./...`)},
55 },
56 },
57 {
58 name: "mention a devtool in the readme",
59 fsys: fstest.MapFS{
60 "readme.md": &fstest.MapFile{Data: []byte("# Test Project\nYou must install `dot` to run the tests.")},
61 },
62 },
63 {
64 name: "empty repo",
65 fsys: fstest.MapFS{
66 "main.go": &fstest.MapFile{Data: []byte("package main\n\nfunc main() {}")},
67 },
68 },
69 {
70 name: "python misery",
71 fsys: fstest.MapFS{
72 "README.md": &fstest.MapFile{Data: []byte("# Our amazing repo\n\nTo use this project you need python 3.11 and the dvc tool")},
73 },
74 },
75 }
76
77 for _, tt := range tests {
78 t.Run(tt.name, func(t *testing.T) {
79 basePath := "testdata/" + strings.ToLower(strings.Replace(t.Name(), "/", "_", -1))
80 rrPath := basePath + ".httprr"
81 rr, err := httprr.Open(rrPath, http.DefaultTransport)
82 if err != nil && !os.IsNotExist(err) {
83 t.Fatal(err)
84 }
85 rr.ScrubReq(func(req *http.Request) error {
86 req.Header.Del("x-api-key")
87 return nil
88 })
89 initFiles, err := readInitFiles(tt.fsys)
90 if err != nil {
91 t.Fatal(err)
92 }
David Crawshaw3659d872025-05-05 17:52:23 -070093 apiKey := cmp.Or(os.Getenv("OUTER_SKETCH_MODEL_API_KEY"), os.Getenv("ANTHROPIC_API_KEY"))
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070094 srv := &ant.Service{
95 APIKey: apiKey,
96 HTTPC: rr.Client(),
97 }
Pokey Rulec31e2962025-05-13 10:53:33 +000098 result, err := createDockerfile(ctx, srv, initFiles, "", false)
Earl Lee2e463fb2025-04-17 11:22:22 -070099 if err != nil {
100 t.Fatal(err)
101 }
102
103 wantPath := basePath + ".dockerfile"
104
105 if *flagRewriteWant {
106 if err := os.WriteFile(wantPath, []byte(result), 0o666); err != nil {
107 t.Fatal(err)
108 }
109 return
110 }
111
112 wantBytes, err := os.ReadFile(wantPath)
113 if err != nil {
114 t.Fatal(err)
115 }
116 want := string(wantBytes)
Josh Bleecher Snyder4d5e9972025-05-01 15:56:37 -0700117 if diff := gcmp.Diff(want, result); diff != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -0700118 t.Errorf("dockerfile does not match. got:\n----\n%s\n----\n\ndiff: %s", result, diff)
119 }
120 })
121 }
122}
123
124func TestReadInitFiles(t *testing.T) {
125 testFS := fstest.MapFS{
126 "README.md": &fstest.MapFile{Data: []byte("# Test Repo")},
127 ".github/workflows/test.yml": &fstest.MapFile{Data: []byte("name: Test Workflow")},
128 "main.go": &fstest.MapFile{Data: []byte("package main")},
129 ".git/HEAD": &fstest.MapFile{Data: []byte("ref: refs/heads/main")},
130 "random/README.md": &fstest.MapFile{Data: []byte("ignore me")},
131 }
132
133 files, err := readInitFiles(testFS)
134 if err != nil {
135 t.Fatalf("readInitFiles failed: %v", err)
136 }
137
138 // Should have 2 files: README.md and .github/workflows/test.yml
139 if len(files) != 2 {
140 t.Errorf("Expected 2 files, got %d", len(files))
141 }
142
143 if content, ok := files["README.md"]; !ok {
144 t.Error("README.md not found")
145 } else if content != "# Test Repo" {
146 t.Errorf("README.md has incorrect content: %q", content)
147 }
148
149 if content, ok := files[".github/workflows/test.yml"]; !ok {
150 t.Error(".github/workflows/test.yml not found")
151 } else if content != "name: Test Workflow" {
152 t.Errorf("Workflow file has incorrect content: %q", content)
153 }
154
155 if _, ok := files["main.go"]; ok {
156 t.Error("main.go should not be included")
157 }
158
159 if _, ok := files[".git/HEAD"]; ok {
160 t.Error(".git/HEAD should not be included")
161 }
162}
163
164func TestReadInitFilesWithSubdir(t *testing.T) {
165 // Create a file system with files in a subdirectory
166 testFS := fstest.MapFS{
167 "subdir/README.md": &fstest.MapFile{Data: []byte("# Test Repo")},
168 "subdir/.github/workflows/test.yml": &fstest.MapFile{Data: []byte("name: Test Workflow")},
169 "subdir/main.go": &fstest.MapFile{Data: []byte("package main")},
170 }
171
172 // Use fs.Sub to get a sub-filesystem
173 subFS, err := fs.Sub(testFS, "subdir")
174 if err != nil {
175 t.Fatalf("fs.Sub failed: %v", err)
176 }
177
178 files, err := readInitFiles(subFS)
179 if err != nil {
180 t.Fatalf("readInitFiles failed: %v", err)
181 }
182
183 // Should have 2 files: README.md and .github/workflows/test.yml
184 if len(files) != 2 {
185 t.Errorf("Expected 2 files, got %d", len(files))
186 }
187
188 // Verify README.md was found
189 if content, ok := files["README.md"]; !ok {
190 t.Error("README.md not found")
191 } else if content != "# Test Repo" {
192 t.Errorf("README.md has incorrect content: %q", content)
193 }
194
195 // Verify workflow file was found
196 if content, ok := files[".github/workflows/test.yml"]; !ok {
197 t.Error(".github/workflows/test.yml not found")
198 } else if content != "name: Test Workflow" {
199 t.Errorf("Workflow file has incorrect content: %q", content)
200 }
201}
David Crawshaw11129492025-04-25 20:41:53 -0700202
203// TestDockerHashIsPushed ensures that any changes made to the
204// dockerfile template have been pushed to the default image.
205func TestDockerHashIsPushed(t *testing.T) {
David Crawshaw2a5bd6d2025-04-30 14:29:46 -0700206 name, _, tag := DefaultImage()
David Crawshaw11129492025-04-25 20:41:53 -0700207
David Crawshaw2a5bd6d2025-04-30 14:29:46 -0700208 if err := checkTagExists(tag); err != nil {
209 if strings.Contains(err.Error(), "not found") {
210 t.Fatalf(`Currently released docker image %s does not match dockerfileCustomTmpl.
David Crawshaw11129492025-04-25 20:41:53 -0700211
David Crawshaw2a5bd6d2025-04-30 14:29:46 -0700212Inspecting the docker image shows the current hash of dockerfileBase is %s,
213but it is not published in the GitHub container registry.
David Crawshaw11129492025-04-25 20:41:53 -0700214
215This means the template constants in createdockerfile.go have been
David Crawshaw2a5bd6d2025-04-30 14:29:46 -0700216edited (e.g. dockerfileBase changed), but a new version
David Crawshaw11129492025-04-25 20:41:53 -0700217of the public default docker image has not been built and pushed.
218
219To do so:
220
221 go run ./dockerimg/pushdockerimg.go
222
David Crawshaw2a5bd6d2025-04-30 14:29:46 -0700223`, name, tag)
224 } else {
225 t.Fatalf("checkTagExists: %v", err)
226 }
David Crawshaw11129492025-04-25 20:41:53 -0700227 }
228}
Josh Bleecher Snyder3e6a4c42025-05-23 17:29:57 +0000229
230func TestGetHostGoCacheDirs(t *testing.T) {
231 ctx := context.Background()
232
233 // Test getHostGoCacheDir
234 goCacheDir, err := getHostGoCacheDir(ctx)
235 if err != nil {
236 t.Fatalf("getHostGoCacheDir failed: %v", err)
237 }
238 if goCacheDir == "" {
239 t.Fatal("getHostGoCacheDir returned empty string")
240 }
241 t.Logf("GOCACHE: %s", goCacheDir)
242
243 // Test getHostGoModCacheDir
244 goModCacheDir, err := getHostGoModCacheDir(ctx)
245 if err != nil {
246 t.Fatalf("getHostGoModCacheDir failed: %v", err)
247 }
248 if goModCacheDir == "" {
249 t.Fatal("getHostGoModCacheDir returned empty string")
250 }
251 t.Logf("GOMODCACHE: %s", goModCacheDir)
252
253 // Both should be absolute paths
254 if !filepath.IsAbs(goCacheDir) {
255 t.Errorf("GOCACHE is not an absolute path: %s", goCacheDir)
256 }
257 if !filepath.IsAbs(goModCacheDir) {
258 t.Errorf("GOMODCACHE is not an absolute path: %s", goModCacheDir)
259 }
260}
Jon Friesend27921f2025-06-05 13:15:56 +0000261
262func TestPrioritizeDockerfiles(t *testing.T) {
263 tests := []struct {
264 name string
265 candidates []string
266 want string
267 }{
268 {
269 name: "empty list",
270 candidates: []string{},
271 want: "",
272 },
273 {
274 name: "single dockerfile",
275 candidates: []string{"/path/to/Dockerfile"},
276 want: "/path/to/Dockerfile",
277 },
278 {
279 name: "dockerfile.sketch preferred over dockerfile",
280 candidates: []string{"/path/to/Dockerfile", "/path/to/Dockerfile.sketch"},
281 want: "/path/to/Dockerfile.sketch",
282 },
283 {
284 name: "dockerfile.sketch preferred regardless of order",
285 candidates: []string{"/path/to/Dockerfile.dev", "/path/to/Dockerfile", "/path/to/Dockerfile.sketch"},
286 want: "/path/to/Dockerfile.sketch",
287 },
288 {
289 name: "dockerfile preferred over other variations",
290 candidates: []string{"/path/to/Dockerfile.dev", "/path/to/Dockerfile", "/path/to/Dockerfile.prod"},
291 want: "/path/to/Dockerfile",
292 },
293 {
294 name: "case insensitive dockerfile.sketch",
295 candidates: []string{"/path/to/dockerfile", "/path/to/dockerfile.sketch"},
296 want: "/path/to/dockerfile.sketch",
297 },
298 {
299 name: "case insensitive dockerfile",
300 candidates: []string{"/path/to/dockerfile.dev", "/path/to/dockerfile"},
301 want: "/path/to/dockerfile",
302 },
303 {
304 name: "first candidate when no priority matches",
305 candidates: []string{"/path/to/Dockerfile.dev", "/path/to/Dockerfile.prod"},
306 want: "/path/to/Dockerfile.dev",
307 },
308 }
309
310 for _, tt := range tests {
311 t.Run(tt.name, func(t *testing.T) {
312 got := prioritizeDockerfiles(tt.candidates)
313 if got != tt.want {
314 t.Errorf("prioritizeDockerfiles() = %v, want %v", got, tt.want)
315 }
316 })
317 }
318}
319
320func TestFindDirDockerfilesIntegration(t *testing.T) {
321 // Create a temporary directory structure for testing
322 tmpDir, err := os.MkdirTemp("", "dockerfiles-test-*")
323 if err != nil {
324 t.Fatal(err)
325 }
326 defer os.RemoveAll(tmpDir)
327
328 // Create test files
329 testFiles := []string{
330 "Dockerfile",
331 "Dockerfile.sketch",
332 "Dockerfile.dev",
333 "dockerfile.prod", // lowercase
334 "README.md", // should be ignored
335 }
336
337 for _, file := range testFiles {
338 path := filepath.Join(tmpDir, file)
Autoformatter3d040bd2025-06-06 02:35:19 +0000339 if err := os.WriteFile(path, []byte("# test"), 0o644); err != nil {
Jon Friesend27921f2025-06-05 13:15:56 +0000340 t.Fatal(err)
341 }
342 }
343
344 // Test findDirDockerfiles
345 candidates, err := findDirDockerfiles(tmpDir)
346 if err != nil {
347 t.Fatal(err)
348 }
349
350 // Should find all Dockerfile* files but not README.md
351 expectedCount := 4
352 if len(candidates) != expectedCount {
353 t.Errorf("findDirDockerfiles() found %d files, want %d", len(candidates), expectedCount)
354 }
355
356 // Test prioritization
357 prioritized := prioritizeDockerfiles(candidates)
358 expectedPriority := filepath.Join(tmpDir, "Dockerfile.sketch")
359 if prioritized != expectedPriority {
360 t.Errorf("prioritizeDockerfiles() = %v, want %v", prioritized, expectedPriority)
361 }
362
363 // Test with only Dockerfile (no Dockerfile.sketch)
364 os.Remove(filepath.Join(tmpDir, "Dockerfile.sketch"))
365 candidates, err = findDirDockerfiles(tmpDir)
366 if err != nil {
367 t.Fatal(err)
368 }
369 prioritized = prioritizeDockerfiles(candidates)
370 expectedPriority = filepath.Join(tmpDir, "Dockerfile")
371 if prioritized != expectedPriority {
372 t.Errorf("prioritizeDockerfiles() without sketch = %v, want %v", prioritized, expectedPriority)
373 }
374}
375
376func TestFindRepoDockerfiles(t *testing.T) {
377 // Create a temporary directory structure that simulates a git repo
378 tmpDir, err := os.MkdirTemp("", "repo-test-*")
379 if err != nil {
380 t.Fatal(err)
381 }
382 defer os.RemoveAll(tmpDir)
383
384 // Create subdirectories
385 subDir := filepath.Join(tmpDir, "subdir")
Autoformatter3d040bd2025-06-06 02:35:19 +0000386 if err := os.MkdirAll(subDir, 0o755); err != nil {
Jon Friesend27921f2025-06-05 13:15:56 +0000387 t.Fatal(err)
388 }
389
390 // Create Dockerfile in subdirectory
391 subDockerfile := filepath.Join(subDir, "Dockerfile")
Autoformatter3d040bd2025-06-06 02:35:19 +0000392 if err := os.WriteFile(subDockerfile, []byte("FROM ubuntu"), 0o644); err != nil {
Jon Friesend27921f2025-06-05 13:15:56 +0000393 t.Fatal(err)
394 }
395
396 // Create Dockerfile.sketch in parent directory
397 rootSketchDockerfile := filepath.Join(tmpDir, "Dockerfile.sketch")
Autoformatter3d040bd2025-06-06 02:35:19 +0000398 if err := os.WriteFile(rootSketchDockerfile, []byte("FROM alpine"), 0o644); err != nil {
Jon Friesend27921f2025-06-05 13:15:56 +0000399 t.Fatal(err)
400 }
401
402 // Test: when called from subdirectory, should find subdirectory Dockerfile first
403 candidates, err := findRepoDockerfiles(subDir, tmpDir)
404 if err != nil {
405 t.Fatal(err)
406 }
407
408 if len(candidates) == 0 {
409 t.Fatal("expected to find at least one dockerfile")
410 }
411
412 // Should find the Dockerfile in the subdirectory
413 if len(candidates) != 1 || candidates[0] != subDockerfile {
414 t.Errorf("expected to find %s, but got %v", subDockerfile, candidates)
415 }
416
417 // Test: when called from root with no subdirectory dockerfile, should find root dockerfile
418 os.Remove(subDockerfile) // Remove subdirectory dockerfile
419 candidates, err = findRepoDockerfiles(tmpDir, tmpDir)
420 if err != nil {
421 t.Fatal(err)
422 }
423
424 if len(candidates) != 1 || candidates[0] != rootSketchDockerfile {
425 t.Errorf("expected to find %s, but got %v", rootSketchDockerfile, candidates)
426 }
427
428 // Test prioritization: create both Dockerfile and Dockerfile.sketch in same directory
429 rootDockerfile := filepath.Join(tmpDir, "Dockerfile")
Autoformatter3d040bd2025-06-06 02:35:19 +0000430 if err := os.WriteFile(rootDockerfile, []byte("FROM debian"), 0o644); err != nil {
Jon Friesend27921f2025-06-05 13:15:56 +0000431 t.Fatal(err)
432 }
433
434 candidates, err = findRepoDockerfiles(tmpDir, tmpDir)
435 if err != nil {
436 t.Fatal(err)
437 }
438
439 if len(candidates) != 2 {
440 t.Errorf("expected to find 2 dockerfiles, but got %d", len(candidates))
441 }
442
443 // Test that prioritization works correctly
444 prioritized := prioritizeDockerfiles(candidates)
445 if prioritized != rootSketchDockerfile {
446 t.Errorf("expected Dockerfile.sketch to be prioritized, but got %s", prioritized)
447 }
448}
Philip Zeyliger2343f8a2025-06-17 06:16:19 -0700449
450// TestDockerfileSketchImageReuse tests that Docker images built from Dockerfile.sketch
451// are properly reused when the Dockerfile content hasn't changed.
452func TestDockerfileSketchImageReuse(t *testing.T) {
453 // Create a temporary directory for the test repo
454 tmpDir := t.TempDir()
455
456 // Create a simple Dockerfile.sketch
457 dockerfileContent := `FROM ubuntu:24.04
458LABEL test=true
459CMD ["echo", "hello"]
460`
461 dockerfilePath := filepath.Join(tmpDir, "Dockerfile.sketch")
462 err := os.WriteFile(dockerfilePath, []byte(dockerfileContent), 0o644)
463 if err != nil {
464 t.Fatalf("Failed to write Dockerfile.sketch: %v", err)
465 }
466
467 // Test that hashInitFiles produces consistent results
468 initFiles := map[string]string{
469 dockerfilePath: dockerfileContent,
470 }
471 hash1 := hashInitFiles(initFiles)
472 hash2 := hashInitFiles(initFiles)
473
474 if hash1 != hash2 {
475 t.Errorf("hashInitFiles should be deterministic, got %s and %s", hash1, hash2)
476 }
477
478 // Test that hash changes when content changes
479 modifiedFiles := map[string]string{
480 dockerfilePath: dockerfileContent + "RUN echo modified\n",
481 }
482 hash3 := hashInitFiles(modifiedFiles)
483
484 if hash1 == hash3 {
485 t.Errorf("hashInitFiles should produce different hashes for different content, got %s for both", hash1)
486 }
487
488 t.Logf("Original hash: %s", hash1)
489 t.Logf("Modified hash: %s", hash3)
490}
491
492// TestPrioritizeDockerfiles tests the Dockerfile prioritization logic
493func TestDockerfileSketchPriority(t *testing.T) {
494 tests := []struct {
495 name string
496 candidates []string
497 expected string
498 }{
499 {
500 name: "dockerfile.sketch_wins_over_dockerfile",
501 candidates: []string{"/path/Dockerfile", "/path/Dockerfile.sketch"},
502 expected: "/path/Dockerfile.sketch",
503 },
504 {
505 name: "dockerfile.sketch_case_insensitive",
506 candidates: []string{"/path/dockerfile", "/path/dockerfile.sketch"},
507 expected: "/path/dockerfile.sketch",
508 },
509 {
510 name: "dockerfile_wins_over_variations",
511 candidates: []string{"/path/Dockerfile.dev", "/path/Dockerfile"},
512 expected: "/path/Dockerfile",
513 },
514 }
515
516 for _, tt := range tests {
517 t.Run(tt.name, func(t *testing.T) {
518 result := prioritizeDockerfiles(tt.candidates)
519 if result != tt.expected {
520 t.Errorf("prioritizeDockerfiles(%v) = %s, want %s", tt.candidates, result, tt.expected)
521 }
522 })
523 }
524}