blob: 4bba8518d0ce49cfd15d79ef287527f6c4a1c05b [file] [log] [blame]
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001package codereview
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +00002
3import (
4 "bytes"
5 "cmp"
6 "context"
7 "flag"
8 "fmt"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "slices"
13 "strings"
14 "testing"
15
16 "golang.org/x/tools/txtar"
17)
18
19// updateTests is set to true when the -update flag is used.
20// This will update the expected test results instead of failing tests.
21var updateTests = flag.Bool("update", false, "update expected test results instead of failing tests")
22
23// TestCodereviewDifferential runs all the end-to-end tests for the codereview and differential packages.
24// Each test is defined in a .txtar file in the testdata directory.
25func TestCodereviewDifferential(t *testing.T) {
26 entries, err := os.ReadDir("testdata")
27 if err != nil {
28 t.Fatalf("failed to read testdata directory: %v", err)
29 }
30 for _, entry := range entries {
31 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".txtar") {
32 continue
33 }
34 testPath := filepath.Join("testdata", entry.Name())
35 testName := strings.TrimSuffix(entry.Name(), ".txtar")
36 t.Run(testName, func(t *testing.T) {
37 t.Parallel()
38 runE2ETest(t, testPath, *updateTests)
39 })
40 }
41}
42
43// runE2ETest executes a single end-to-end test from a .txtar file.
44func runE2ETest(t *testing.T, testPath string, update bool) {
45 orig, err := os.ReadFile(testPath)
46 if err != nil {
47 t.Fatalf("failed to read test file %s: %v", testPath, err)
48 }
49 archive, err := txtar.ParseFile(testPath)
50 if err != nil {
51 t.Fatalf("failed to parse txtar file %s: %v", testPath, err)
52 }
53
54 tmpDir := t.TempDir()
55 // resolve temp dir path so that we can canonicalize/normalize it later
56 tmpDir = resolveRealPath(tmpDir)
57
58 if err := initGoModule(tmpDir); err != nil {
59 t.Fatalf("failed to initialize Go module: %v", err)
60 }
61 if err := initGitRepo(tmpDir); err != nil {
62 t.Fatalf("failed to initialize git repository: %v", err)
63 }
64 if err := processTestFiles(t, archive, tmpDir, update); err != nil {
65 t.Fatalf("error processing test files: %v", err)
66 }
67
68 // If we're updating, write back the modified archive to the file
69 if update {
70 updatedContent := txtar.Format(archive)
71 // only write back changes, avoids git status churn
72 if !bytes.Equal(orig, updatedContent) {
73 if err := os.WriteFile(testPath, updatedContent, 0o644); err != nil {
74 t.Errorf("Failed to update test file %s: %v", testPath, err)
75 }
76 }
77 }
78}
79
80func gitCommitEnv() []string {
81 return append(os.Environ(),
82 "GIT_AUTHOR_NAME=Test Author",
83 "GIT_AUTHOR_EMAIL=test@example.com",
84 "GIT_COMMITTER_NAME=Test Committer",
85 "GIT_COMMITTER_EMAIL=test@example.com",
86 "GIT_AUTHOR_DATE=2025-01-01T00:00:00Z",
87 "GIT_COMMITTER_DATE=2025-01-01T00:00:00Z",
88 )
89}
90
91// initGitRepo initializes a new git repository in the specified directory.
92func initGitRepo(dir string) error {
93 cmd := exec.Command("git", "init")
94 cmd.Dir = dir
95 err := cmd.Run()
96 if err != nil {
97 return fmt.Errorf("error initializing git repository: %w", err)
98 }
99 // create a single commit out of everything there now
100 cmd = exec.Command("git", "add", ".")
101 cmd.Dir = dir
102 err = cmd.Run()
103 if err != nil {
104 return fmt.Errorf("error staging files: %w", err)
105 }
106 cmd = exec.Command("git", "commit", "-m", "create repo")
107 cmd.Dir = dir
108 cmd.Env = gitCommitEnv()
109 err = cmd.Run()
110 if err != nil {
111 return fmt.Errorf("error making initial commit: %w", err)
112 }
113 return nil
114}
115
116// processTestFiles processes the files in the txtar archive in sequence.
117func processTestFiles(t *testing.T, archive *txtar.Archive, dir string, update bool) error {
118 var initialCommit string
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000119 var reviewer *CodeReviewer
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000120 filesForNextCommit := make(map[string]bool)
121
122 for i, file := range archive.Files {
123 switch file.Name {
124 case ".commit":
125 commit, err := makeGitCommit(dir, string(file.Data), filesForNextCommit)
126 if err != nil {
127 return fmt.Errorf("error making git commit: %w", err)
128 }
129 clear(filesForNextCommit)
130 initialCommit = cmp.Or(initialCommit, commit)
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000131 if reviewer == nil && initialCommit != "" {
132 reviewer, err = NewCodeReviewer(context.Background(), dir, initialCommit)
133 if err != nil {
134 return fmt.Errorf("error creating code reviewer: %w", err)
135 }
136 }
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000137
138 case ".run_test":
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000139 if reviewer == nil {
140 return fmt.Errorf("no code reviewer available, need initial commit first")
141 }
142 got, err := runDifferentialTest(reviewer)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000143 if err != nil {
144 return fmt.Errorf("error running differential test: %w", err)
145 }
146 want := string(file.Data)
147
Josh Bleecher Snyder2fde4652025-05-01 17:50:34 -0700148 commitCleaner := strings.NewReplacer(initialCommit, "INITIAL_COMMIT_HASH")
149 got = commitCleaner.Replace(got)
150 want = commitCleaner.Replace(want)
151
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000152 if update {
153 archive.Files[i].Data = []byte(got)
154 break
155 }
156 if strings.TrimSpace(want) != strings.TrimSpace(got) {
157 t.Errorf("Results don't match.\nExpected:\n%s\n\nActual:\n%s", want, got)
158 }
159
160 case ".run_autoformat":
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000161 if reviewer == nil {
162 return fmt.Errorf("no code reviewer available, need initial commit first")
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000163 }
164
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000165 got, err := runAutoformat(reviewer)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000166 if err != nil {
167 return fmt.Errorf("error running autoformat: %w", err)
168 }
169 slices.Sort(got)
170
171 if update {
172 correct := strings.Join(got, "\n") + "\n"
173 archive.Files[i].Data = []byte(correct)
174 break
175 }
176
177 want := strings.Split(strings.TrimSpace(string(file.Data)), "\n")
178 if !slices.Equal(want, got) {
179 t.Errorf("Formatted files don't match.\nExpected:\n%s\n\nActual:\n%s", want, got)
180 }
181
182 default:
183 filePath := filepath.Join(dir, file.Name)
184 if err := os.MkdirAll(filepath.Dir(filePath), 0o700); err != nil {
185 return fmt.Errorf("error creating directory for %s: %w", file.Name, err)
186 }
187 data := file.Data
188 // Remove second trailing newline if present.
189 // An annoyance of the txtar format, messes with gofmt.
190 if bytes.HasSuffix(data, []byte("\n\n")) {
191 data = bytes.TrimSuffix(data, []byte("\n"))
192 }
193 if err := os.WriteFile(filePath, file.Data, 0o600); err != nil {
194 return fmt.Errorf("error writing file %s: %w", file.Name, err)
195 }
196 filesForNextCommit[file.Name] = true
197 }
198 }
199
200 return nil
201}
202
203// makeGitCommit commits the specified files with the given message.
204// Returns the commit hash.
205func makeGitCommit(dir, message string, files map[string]bool) (string, error) {
206 for file := range files {
207 cmd := exec.Command("git", "add", file)
208 cmd.Dir = dir
209 if err := cmd.Run(); err != nil {
210 return "", fmt.Errorf("error staging file %s: %w", file, err)
211 }
212 }
213 message = cmp.Or(message, "Test commit")
214
215 // Make the commit with fixed author, committer, and date for stable hashes
216 cmd := exec.Command("git", "commit", "--allow-empty", "-m", message)
217 cmd.Dir = dir
218 cmd.Env = gitCommitEnv()
219 if err := cmd.Run(); err != nil {
220 return "", fmt.Errorf("error making commit: %w", err)
221 }
222
223 // Get the commit hash
224 cmd = exec.Command("git", "rev-parse", "HEAD")
225 cmd.Dir = dir
226 out, err := cmd.Output()
227 if err != nil {
228 return "", fmt.Errorf("error getting commit hash: %w", err)
229 }
230
231 return strings.TrimSpace(string(out)), nil
232}
233
234// runDifferentialTest runs the code review tool on the repository and returns the result.
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000235func runDifferentialTest(reviewer *CodeReviewer) (string, error) {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000236 ctx := context.Background()
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700237 toolOut := reviewer.Run(ctx, nil)
238 if toolOut.Error != nil {
239 return "", fmt.Errorf("error running code review: %w", toolOut.Error)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000240 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700241 result := toolOut.LLMContent
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000242
243 // Normalize paths in the result
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700244 resultStr := ""
245 if len(result) > 0 {
246 resultStr = result[0].Text
247 }
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000248 dir := reviewer.repoRoot
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700249 normalized := normalizePaths(resultStr, dir)
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000250 return normalized, nil
251}
252
253// normalizePaths replaces the temp directory paths with a standard placeholder
254func normalizePaths(result string, tempDir string) string {
255 return strings.ReplaceAll(result, tempDir, "/PATH/TO/REPO")
256}
257
258// initGoModule initializes a Go module in the specified directory.
259func initGoModule(dir string) error {
260 cmd := exec.Command("go", "mod", "init", "sketch.dev")
261 cmd.Dir = dir
262 return cmd.Run()
263}
264
265// runAutoformat runs the autoformat function on the repository and returns the list of formatted files.
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000266func runAutoformat(reviewer *CodeReviewer) ([]string, error) {
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000267 ctx := context.Background()
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +0000268 formattedFiles := reviewer.autoformat(ctx)
Josh Bleecher Snyder26b6f9b2025-07-01 01:41:11 +0000269 dir := reviewer.repoRoot
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000270 normalizedFiles := make([]string, len(formattedFiles))
271 for i, file := range formattedFiles {
272 normalizedFiles[i] = normalizePaths(file, dir)
273 }
274 slices.Sort(normalizedFiles)
275 return normalizedFiles, nil
276}
277
278// resolveRealPath follows symlinks and returns the real path
279// This handles platform-specific behaviors like macOS's /private prefix
280func resolveRealPath(path string) string {
281 // Follow symlinks to get the real path
282 realPath, err := filepath.EvalSymlinks(path)
283 if err != nil {
284 // If we can't resolve symlinks, just return the original path
285 return path
286 }
287 return realPath
288}