Approval-based snapshot testing library for Go (mirror)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: tui/cli now walk project tree to find `__snapshots__` dirs

+177 -45
+1 -1
cmd/tui/go.mod
··· 3 3 go 1.25.2 4 4 5 5 require ( 6 + github.com/charmbracelet/bubbles v0.21.0 6 7 github.com/charmbracelet/bubbletea v1.3.10 7 8 github.com/charmbracelet/lipgloss v1.1.0 8 - github.com/charmbracelet/bubbles v0.21.0 9 9 github.com/ptdewey/shutter v0.0.0 10 10 ) 11 11
+2
cmd/tui/go.sum
··· 16 16 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 17 17 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 18 18 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 19 + github.com/kortschak/utter v1.7.0 h1:6NKMynvGUyqfeMTawfah4zyInlrgwzjkDAHrT+skx/w= 20 + github.com/kortschak/utter v1.7.0/go.mod h1:vSmSjbyrlKjjsL71193LmzBOKgwePk9DH6uFaWHIInc= 19 21 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 22 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 23 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+16 -16
cmd/tui/main.go
··· 57 57 ) 58 58 59 59 type model struct { 60 - snapshots []string 60 + snapshots []files.SnapshotInfo 61 61 current int 62 62 newSnap *files.Snapshot 63 63 accepted *files.Snapshot ··· 103 103 return nil 104 104 } 105 105 106 - testName := m.snapshots[m.current] 106 + snapshotInfo := m.snapshots[m.current] 107 107 108 - newSnap, err := files.ReadSnapshot(testName, "new") 108 + newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) 109 109 if err != nil { 110 110 return err 111 111 } 112 112 m.newSnap = newSnap 113 113 114 - accepted, err := files.ReadSnapshot(testName, "accepted") 114 + accepted, err := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") 115 115 if err == nil { 116 116 m.accepted = accepted 117 117 diffLines := computeDiffLines(accepted, newSnap) ··· 166 166 167 167 case "a": 168 168 // Accept current snapshot 169 - testName := m.snapshots[m.current] 170 - if err := files.AcceptSnapshot(testName); err != nil { 169 + snapshotInfo := m.snapshots[m.current] 170 + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { 171 171 m.err = err 172 172 } else { 173 173 m.acceptedAll++ ··· 183 183 184 184 case "r": 185 185 // Reject current snapshot 186 - testName := m.snapshots[m.current] 187 - if err := files.RejectSnapshot(testName); err != nil { 186 + snapshotInfo := m.snapshots[m.current] 187 + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { 188 188 m.err = err 189 189 } else { 190 190 m.rejectedAll++ ··· 213 213 case "A": 214 214 // Accept all remaining 215 215 for i := m.current; i < len(m.snapshots); i++ { 216 - if err := files.AcceptSnapshot(m.snapshots[i]); err != nil { 216 + if err := files.AcceptSnapshotInfo(m.snapshots[i]); err != nil { 217 217 m.err = err 218 218 break 219 219 } ··· 225 225 case "R": 226 226 // Reject all remaining 227 227 for i := m.current; i < len(m.snapshots); i++ { 228 - if err := files.RejectSnapshot(m.snapshots[i]); err != nil { 228 + if err := files.RejectSnapshotInfo(m.snapshots[i]); err != nil { 229 229 m.err = err 230 230 break 231 231 } ··· 327 327 } 328 328 329 329 // Header 330 - snapshotTitle := m.snapshots[m.current] // fallback to test name 330 + snapshotTitle := m.snapshots[m.current].Title // fallback to snapshot title 331 331 if m.newSnap != nil && m.newSnap.Title != "" { 332 332 snapshotTitle = m.newSnap.Title 333 333 } ··· 339 339 headerStyled := statusBarStyle.Width(m.width).Render(header) 340 340 341 341 // Footer with snapshot filename and scroll info 342 - snapshotFile := files.SnapshotFileName(m.snapshots[m.current]) + ".snap.new" 342 + snapshotFile := files.SnapshotFileName(m.snapshots[m.current].Title) + ".snap.new" 343 343 fileInfo := helpStyle.Render(snapshotFile) 344 344 scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) 345 345 scrollStyled := helpStyle.Render(scrollInfo) ··· 375 375 return err 376 376 } 377 377 378 - for _, testName := range snapshots { 379 - if err := files.AcceptSnapshot(testName); err != nil { 378 + for _, snapshotInfo := range snapshots { 379 + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { 380 380 return err 381 381 } 382 382 } ··· 391 391 return err 392 392 } 393 393 394 - for _, testName := range snapshots { 395 - if err := files.RejectSnapshot(testName); err != nil { 394 + for _, snapshotInfo := range snapshots { 395 + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { 396 396 return err 397 397 } 398 398 }
+3 -1
go.mod
··· 1 1 module github.com/ptdewey/shutter 2 2 3 - go 1.25.2 3 + go 1.23.12 4 + 5 + toolchain go1.25.2 4 6 5 7 require github.com/kortschak/utter v1.7.0
+117 -8
internal/files/files.go
··· 63 63 return snap, nil 64 64 } 65 65 66 + // getSnapshotDir finds the nearest __snapshots__ directory relative to the caller, 67 + // creating one if it doesn't exist. This is used when creating new snapshots. 66 68 func getSnapshotDir() (string, error) { 67 69 // NOTE: maybe this could be configurable? 68 70 // Storing snapshots in root may be desirable in some cases ··· 74 76 return snapshotDir, nil 75 77 } 76 78 79 + // findAllSnapshotDirs recursively finds all __snapshots__ directories starting from root 80 + func findAllSnapshotDirs(root string) ([]string, error) { 81 + var snapshotDirs []string 82 + 83 + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 84 + if err != nil { 85 + return err 86 + } 87 + 88 + // Skip hidden directories and common ignore paths 89 + if info.IsDir() && len(info.Name()) > 0 && info.Name()[0] == '.' { 90 + return filepath.SkipDir 91 + } 92 + if info.IsDir() && (info.Name() == "node_modules" || info.Name() == "vendor") { 93 + return filepath.SkipDir 94 + } 95 + 96 + if info.IsDir() && info.Name() == "__snapshots__" { 97 + snapshotDirs = append(snapshotDirs, path) 98 + } 99 + 100 + return nil 101 + }) 102 + 103 + return snapshotDirs, err 104 + } 105 + 106 + // findProjectRoot finds the root of the project by looking for go.mod 107 + func findProjectRoot() (string, error) { 108 + cwd, err := os.Getwd() 109 + if err != nil { 110 + return "", err 111 + } 112 + 113 + dir := cwd 114 + for { 115 + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { 116 + return dir, nil 117 + } 118 + 119 + parent := filepath.Dir(dir) 120 + if parent == dir { 121 + // Reached filesystem root without finding go.mod 122 + // Fall back to current directory 123 + return cwd, nil 124 + } 125 + dir = parent 126 + } 127 + } 128 + 77 129 // TODO: make this use the snapshot title rather than the test name 78 130 func SnapshotFileName(snapTitle string) string { 79 131 return strings.ReplaceAll(strings.ToLower(snapTitle), " ", "_") ··· 121 173 return nil, err 122 174 } 123 175 176 + return ReadSnapshotWithDir(snapshotDir, snapTitle, state) 177 + } 178 + 179 + // ReadSnapshotFromPath reads a snapshot directly from a full file path 180 + func ReadSnapshotFromPath(filePath string) (*Snapshot, error) { 181 + data, err := os.ReadFile(filePath) 182 + if err != nil { 183 + return nil, err 184 + } 185 + 186 + return Deserialize(string(data)) 187 + } 188 + 189 + // ReadSnapshotWithDir reads a snapshot from a specific directory 190 + func ReadSnapshotWithDir(snapshotDir, snapTitle string, state string) (*Snapshot, error) { 124 191 fileName := getSnapshotFileName(snapTitle, state) 125 192 filePath := filepath.Join(snapshotDir, fileName) 126 193 ··· 140 207 return ReadSnapshot(snapTitle, "new") 141 208 } 142 209 143 - func ListNewSnapshots() ([]string, error) { 144 - snapshotDir, err := getSnapshotDir() 210 + // SnapshotInfo contains metadata about a snapshot file including its full path 211 + type SnapshotInfo struct { 212 + Title string // The snapshot title (used as identifier) 213 + Path string // Full path to the snapshot file 214 + Dir string // Directory containing the snapshot 215 + } 216 + 217 + func ListNewSnapshots() ([]SnapshotInfo, error) { 218 + projectRoot, err := findProjectRoot() 145 219 if err != nil { 146 220 return nil, err 147 221 } 148 222 149 - entries, err := os.ReadDir(snapshotDir) 223 + snapshotDirs, err := findAllSnapshotDirs(projectRoot) 150 224 if err != nil { 151 225 return nil, err 152 226 } 153 227 154 - var newSnapshots []string 155 - for _, entry := range entries { 156 - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".snap.new") { 157 - name := strings.TrimSuffix(entry.Name(), ".snap.new") 158 - newSnapshots = append(newSnapshots, name) 228 + var newSnapshots []SnapshotInfo 229 + for _, dir := range snapshotDirs { 230 + entries, err := os.ReadDir(dir) 231 + if err != nil { 232 + // Skip directories we can't read 233 + continue 234 + } 235 + 236 + for _, entry := range entries { 237 + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".snap.new") { 238 + name := strings.TrimSuffix(entry.Name(), ".snap.new") 239 + fullPath := filepath.Join(dir, entry.Name()) 240 + newSnapshots = append(newSnapshots, SnapshotInfo{ 241 + Title: name, 242 + Path: fullPath, 243 + Dir: dir, 244 + }) 245 + } 159 246 } 160 247 } 161 248 162 249 return newSnapshots, nil 163 250 } 164 251 252 + // AcceptSnapshotInfo accepts a snapshot using SnapshotInfo 253 + func AcceptSnapshotInfo(info SnapshotInfo) error { 254 + newPath := info.Path 255 + acceptedPath := filepath.Join(info.Dir, getSnapshotFileName(info.Title, "accepted")) 256 + 257 + data, err := os.ReadFile(newPath) 258 + if err != nil { 259 + return err 260 + } 261 + 262 + if err := os.WriteFile(acceptedPath, data, 0644); err != nil { 263 + return err 264 + } 265 + 266 + return os.Remove(newPath) 267 + } 268 + 165 269 func AcceptSnapshot(snapTitle string) error { 166 270 newPath, err := getSnapshotPath(snapTitle, "new") 167 271 if err != nil { ··· 183 287 } 184 288 185 289 return os.Remove(newPath) 290 + } 291 + 292 + // RejectSnapshotInfo rejects a snapshot using SnapshotInfo 293 + func RejectSnapshotInfo(info SnapshotInfo) error { 294 + return os.Remove(info.Path) 186 295 } 187 296 188 297 func RejectSnapshot(snapTitle string) error {
+24 -5
internal/files/files_test.go
··· 157 157 t.Fatalf("SaveSnapshot failed: %v", err) 158 158 } 159 159 160 - read, err := files.ReadSnapshot("TestSaveRead", "test") 160 + read, err := files.ReadSnapshot("Save Read Title", "test") 161 161 if err != nil { 162 162 t.Fatalf("ReadSnapshot failed: %v", err) 163 163 } ··· 166 166 t.Errorf("Content mismatch: %s != %s", read.Content, snap.Content) 167 167 } 168 168 169 - cleanupSnapshot(t, "TestSaveRead", "test") 169 + cleanupSnapshot(t, "Save Read Title", "test") 170 170 } 171 171 172 172 func TestReadSnapshotNotFound(t *testing.T) { ··· 191 191 t.Fatalf("AcceptSnapshot failed: %v", err) 192 192 } 193 193 194 - accepted, err := files.ReadSnapshot("TestAccept", "accepted") 194 + accepted, err := files.ReadSnapshot("Accept Title", "accepted") 195 195 if err != nil { 196 196 t.Fatalf("ReadSnapshot failed: %v", err) 197 197 } ··· 200 200 t.Errorf("Content mismatch: %s != %s", accepted.Content, newSnap.Content) 201 201 } 202 202 203 - _, err = files.ReadSnapshot("TestAccept", "new") 203 + _, err = files.ReadSnapshot("Accept Title", "new") 204 204 if err == nil { 205 205 t.Error("expected error: .new file should be deleted after accept") 206 206 } 207 207 208 - cleanupSnapshot(t, "TestAccept", "accepted") 208 + cleanupSnapshot(t, "Accept Title", "accepted") 209 209 } 210 210 211 211 func TestRejectSnapshot(t *testing.T) { ··· 249 249 filePath := filepath.Join(root, "__snapshots__", fileName) 250 250 _ = os.Remove(filePath) 251 251 } 252 + 253 + func TestRecursiveSnapshots(t *testing.T) { 254 + // This test verifies that ListNewSnapshots finds snapshots recursively 255 + snapshots, err := files.ListNewSnapshots() 256 + if err != nil { 257 + t.Fatalf("ListNewSnapshots failed: %v", err) 258 + } 259 + 260 + t.Logf("Found %d snapshots", len(snapshots)) 261 + for _, snap := range snapshots { 262 + t.Logf(" - Title: %s, Path: %s", snap.Title, snap.Path) 263 + } 264 + 265 + // Just verify it doesn't error - we can't make assumptions about which 266 + // snapshots exist since this depends on the test environment 267 + if err != nil { 268 + t.Errorf("Error listing snapshots: %v", err) 269 + } 270 + }
+14 -14
internal/review/review.go
··· 28 28 } 29 29 30 30 // applyToSnapshots applies an operation to all snapshots and returns the count of successful operations 31 - func applyToSnapshots(snapshots []string, operation func(string) error) (int, error) { 31 + func applyToSnapshots(snapshots []files.SnapshotInfo, operation func(files.SnapshotInfo) error) (int, error) { 32 32 successCount := 0 33 - for _, snapTitle := range snapshots { 34 - if err := operation(snapTitle); err != nil { 33 + for _, snapshotInfo := range snapshots { 34 + if err := operation(snapshotInfo); err != nil { 35 35 return successCount, err 36 36 } 37 37 successCount++ ··· 56 56 return reviewLoop(snapshots) 57 57 } 58 58 59 - func reviewLoop(snapshots []string) error { 59 + func reviewLoop(snapshots []files.SnapshotInfo) error { 60 60 reader := bufio.NewReader(os.Stdin) 61 61 62 - for i, snapTitle := range snapshots { 63 - fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(snapTitle)) 62 + for i, snapshotInfo := range snapshots { 63 + fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(snapshotInfo.Title)) 64 64 65 - newSnap, err := files.ReadSnapshot(snapTitle, "new") 65 + newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) 66 66 if err != nil { 67 67 fmt.Println(pretty.Error("✗ Failed to read new snapshot: " + err.Error())) 68 68 continue 69 69 } 70 70 71 - accepted, acceptErr := files.ReadSnapshot(snapTitle, "accepted") 71 + accepted, acceptErr := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") 72 72 73 73 if acceptErr == nil { 74 74 diffLines := computeDiffLines(accepted, newSnap) ··· 85 85 86 86 switch choice { 87 87 case Accept: 88 - if err := files.AcceptSnapshot(snapTitle); err != nil { 88 + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { 89 89 fmt.Println(pretty.Error("✗ Failed to accept snapshot: " + err.Error())) 90 90 } else { 91 91 fmt.Println(pretty.Success("✓ Snapshot accepted")) 92 92 } 93 93 case Reject: 94 - if err := files.RejectSnapshot(snapTitle); err != nil { 94 + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { 95 95 fmt.Println(pretty.Error("✗ Failed to reject snapshot: " + err.Error())) 96 96 } else { 97 97 fmt.Println(pretty.Warning("⊘ Snapshot rejected")) ··· 100 100 fmt.Println(pretty.Warning("⊘ Snapshot skipped")) 101 101 case AcceptAllChoice: 102 102 remaining := snapshots[i:] 103 - if _, err := applyToSnapshots(remaining, files.AcceptSnapshot); err != nil { 103 + if _, err := applyToSnapshots(remaining, files.AcceptSnapshotInfo); err != nil { 104 104 fmt.Println(pretty.Error("✗ Failed to accept snapshot: " + err.Error())) 105 105 return err 106 106 } ··· 108 108 return nil 109 109 case RejectAllChoice: 110 110 remaining := snapshots[i:] 111 - if _, err := applyToSnapshots(remaining, files.RejectSnapshot); err != nil { 111 + if _, err := applyToSnapshots(remaining, files.RejectSnapshotInfo); err != nil { 112 112 fmt.Println(pretty.Error("✗ Failed to reject snapshot: " + err.Error())) 113 113 return err 114 114 } ··· 166 166 return err 167 167 } 168 168 169 - count, err := applyToSnapshots(snapshots, files.AcceptSnapshot) 169 + count, err := applyToSnapshots(snapshots, files.AcceptSnapshotInfo) 170 170 if err != nil { 171 171 return err 172 172 } ··· 181 181 return err 182 182 } 183 183 184 - count, err := applyToSnapshots(snapshots, files.RejectSnapshot) 184 + count, err := applyToSnapshots(snapshots, files.RejectSnapshotInfo) 185 185 if err != nil { 186 186 return err 187 187 }