github.com/grafana/pyroscope@v1.18.0/pkg/frontend/vcs/source/find_test.go (about) 1 package source 2 3 import ( 4 "context" 5 "encoding/base64" 6 "errors" 7 "fmt" 8 "net/http" 9 "path/filepath" 10 "sync" 11 "testing" 12 13 "connectrpc.com/connect" 14 "github.com/go-kit/log" 15 giturl "github.com/kubescape/go-git-url" 16 "github.com/stretchr/testify/assert" 17 "github.com/stretchr/testify/require" 18 19 "github.com/grafana/pyroscope/pkg/frontend/vcs/client" 20 "github.com/grafana/pyroscope/pkg/frontend/vcs/config" 21 ) 22 23 func newMockVCSClient() *mockVCSClient { 24 return &mockVCSClient{ 25 files: make(map[client.FileRequest]client.File), 26 } 27 } 28 29 type mockFileResponse struct { 30 request client.FileRequest 31 content string 32 } 33 34 func newFile(path string) mockFileResponse { 35 return mockFileResponse{ 36 request: client.FileRequest{ 37 Path: path, 38 }, 39 content: "# Content of " + path, 40 } 41 } 42 43 func (f *mockFileResponse) url() string { 44 return fmt.Sprintf( 45 "https://github.com/%s/%s/blob/%s/%s", 46 f.request.Owner, 47 f.request.Repo, 48 f.request.Ref, 49 f.request.Path, 50 ) 51 } 52 53 type mockVCSClient struct { 54 mtx sync.Mutex 55 files map[client.FileRequest]client.File 56 searchedSequence []string 57 } 58 59 func (c *mockVCSClient) GetFile(ctx context.Context, req client.FileRequest) (client.File, error) { 60 c.mtx.Lock() 61 c.searchedSequence = append(c.searchedSequence, req.Path) 62 file, ok := c.files[req] 63 c.mtx.Unlock() 64 if ok { 65 return file, nil 66 } 67 return client.File{}, client.ErrNotFound 68 } 69 70 func (c *mockVCSClient) addFiles(files ...mockFileResponse) *mockVCSClient { 71 c.mtx.Lock() 72 defer c.mtx.Unlock() 73 for _, file := range files { 74 file.request.Owner = defaultOwner(file.request.Owner) 75 file.request.Repo = defaultRepo(file.request.Repo) 76 file.request.Ref = defaultRef(file.request.Ref) 77 c.files[file.request] = client.File{ 78 Content: file.content, 79 URL: file.url(), 80 } 81 } 82 return c 83 } 84 85 func defaultOwner(s string) string { 86 if s == "" { 87 return "grafana" 88 } 89 return s 90 } 91 92 func defaultRepo(s string) string { 93 if s == "" { 94 return "pyroscope" 95 } 96 return s 97 } 98 func defaultRef(s string) string { 99 if s == "" { 100 return "main" 101 } 102 return s 103 } 104 105 const javaPyroscopeYAML = `--- 106 source_code: 107 mappings: 108 - function_name: 109 - prefix: org/example/rideshare 110 language: java 111 source: 112 local: 113 path: src/main/java 114 - function_name: 115 - prefix: java 116 language: java 117 source: 118 github: 119 owner: openjdk 120 repo: jdk 121 ref: jdk-17+0 122 path: src/java.base/share/classes 123 - function_name: 124 - prefix: org/springframework/http 125 - prefix: org/springframework/web 126 language: java 127 source: 128 github: 129 owner: spring-projects 130 repo: spring-framework 131 ref: v5.3.20 132 path: spring-web/src/main/java 133 - function_name: 134 - prefix: org/springframework/web/servlet 135 language: java 136 source: 137 github: 138 owner: spring-projects 139 repo: spring-framework 140 ref: v5.3.20 141 path: spring-webmvc/src/main/java 142 ` 143 144 const goPyroscopeYAML = `--- 145 source_code: 146 mappings: 147 - path: 148 - prefix: $GOROOT/src 149 language: go 150 source: 151 github: 152 owner: golang 153 repo: go 154 ref: go1.24.8 155 path: src 156 ` 157 158 const goPyroscopeYAMLBazel = `--- 159 source_code: 160 mappings: 161 - path: 162 - prefix: external/gazelle++go_deps+com_github_stretchr_testify 163 language: go 164 source: 165 github: 166 owner: stretchr 167 repo: testify 168 ref: v1.10.0 169 ` 170 171 const pythonPyroscopeYAML = `--- 172 source_code: 173 mappings: 174 - path: 175 - prefix: /app/myproject 176 language: python 177 source: 178 local: 179 path: src 180 ` 181 182 // TestFileFinder_Find tests the complete happy path integration for find.go using table-driven tests 183 func TestFileFinder_Find(t *testing.T) { 184 tests := []struct { 185 name string 186 fileSpec config.FileSpec 187 owner string 188 repo string 189 ref string 190 rootPath string 191 pyroscopeYAML string 192 mockFiles []mockFileResponse 193 expectedContent string 194 expectedURL string 195 expectedError bool 196 }{ 197 // Java tests 198 { 199 name: "java/mapped-local-path", 200 fileSpec: config.FileSpec{ 201 FunctionName: "org/example/rideshare/RideShareController.orderCar", 202 }, 203 rootPath: "examples/language-sdk-instrumentation/java/rideshare", 204 ref: "main", 205 pyroscopeYAML: javaPyroscopeYAML, 206 mockFiles: []mockFileResponse{ 207 { 208 request: client.FileRequest{ 209 Repo: "pyroscope", 210 Path: "examples/language-sdk-instrumentation/java/rideshare/src/main/java/org/example/rideshare/RideShareController.java", 211 Ref: "main", 212 }, 213 content: "# CONTENT RideShareController.java", 214 }, 215 }, 216 expectedContent: "# CONTENT RideShareController.java", 217 expectedURL: "https://github.com/grafana/pyroscope/blob/main/examples/language-sdk-instrumentation/java/rideshare/src/main/java/org/example/rideshare/RideShareController.java", 218 expectedError: false, 219 }, 220 { 221 name: "java/mapped-dependency", 222 fileSpec: config.FileSpec{ 223 FunctionName: "java/lang/Math.floorMod", 224 }, 225 rootPath: "examples/language-sdk-instrumentation/java/rideshare", 226 ref: "main", 227 pyroscopeYAML: javaPyroscopeYAML, 228 mockFiles: []mockFileResponse{ 229 { 230 request: client.FileRequest{ 231 Owner: "openjdk", 232 Repo: "jdk", 233 Ref: "jdk-17+0", 234 Path: "src/java.base/share/classes/java/lang/Math.java", 235 }, 236 content: "# CONTENT Math.java", 237 }, 238 }, 239 expectedContent: "# CONTENT Math.java", 240 expectedURL: "https://github.com/openjdk/jdk/blob/jdk-17+0/src/java.base/share/classes/java/lang/Math.java", 241 expectedError: false, 242 }, 243 // Go tests 244 { 245 name: "go/not-mapped-local-path", 246 fileSpec: config.FileSpec{ 247 FunctionName: "github.com/grafana/pyroscope/pkg/compactionworker.(*Worker).runCompaction", 248 Path: "/Users/christian/git/github.com/grafana/pyroscope/pkg/compactionworker/worker.go", 249 }, 250 ref: "main", 251 mockFiles: []mockFileResponse{ 252 { 253 request: client.FileRequest{ 254 Owner: "grafana", 255 Repo: "pyroscope", 256 Ref: "main", 257 Path: "pkg/compactionworker/worker.go", 258 }, 259 content: "# CONTENT worker.go", 260 }, 261 }, 262 expectedContent: "# CONTENT worker.go", 263 expectedURL: "https://github.com/grafana/pyroscope/blob/main/pkg/compactionworker/worker.go", 264 expectedError: false, 265 }, 266 { 267 name: "go/not-mapped-dependency-gomod", 268 fileSpec: config.FileSpec{ 269 FunctionName: "github.com/parquet-go/parquet-go.(*bufferPool).newBuffer", 270 Path: "/Users/christian/.golang/packages/pkg/mod/github.com/parquet-go/parquet-go@v0.23.0/buffer.go", 271 }, 272 ref: "main", 273 mockFiles: []mockFileResponse{ 274 { 275 request: client.FileRequest{ 276 Path: "go.mod", 277 }, 278 content: ` 279 module github.com/grafana/pyroscope 280 281 go 1.24.6 282 283 toolchain go1.24.9 284 285 require ( 286 github.com/parquet-go/parquet-go v0.25.0 287 ) 288 `, 289 }, 290 { 291 request: client.FileRequest{ 292 Owner: "parquet-go", 293 Repo: "parquet-go", 294 Ref: "v0.25.0", 295 Path: "buffer.go", 296 }, 297 content: "# CONTENT buffer.go", 298 }, 299 }, 300 expectedContent: "# CONTENT buffer.go", 301 expectedURL: "https://github.com/parquet-go/parquet-go/blob/v0.25.0/buffer.go", 302 expectedError: false, 303 }, 304 { 305 name: "go/not-mapped-dependency-no-gomod-file", 306 fileSpec: config.FileSpec{ 307 FunctionName: "github.com/parquet-go/parquet-go.(*bufferPool).newBuffer", 308 // without go.mod file in the version of the dependency comes from the file path 309 Path: "/Users/christian/.golang/packages/pkg/mod/github.com/parquet-go/parquet-go@v0.23.0/buffer.go", 310 }, 311 ref: "main", 312 mockFiles: []mockFileResponse{ 313 { 314 request: client.FileRequest{ 315 Owner: "parquet-go", 316 Repo: "parquet-go", 317 Ref: "v0.23.0", 318 Path: "buffer.go", 319 }, 320 content: "# CONTENT buffer.go", 321 }, 322 }, 323 expectedContent: "# CONTENT buffer.go", 324 expectedURL: "https://github.com/parquet-go/parquet-go/blob/v0.23.0/buffer.go", 325 expectedError: false, 326 }, 327 { 328 name: "go/not-mapped-dependency-vendor", 329 fileSpec: config.FileSpec{ 330 FunctionName: "github.com/grafana/loki/v3/pkg/iter/v2.(*PeekIter).cacheNext", 331 Path: "/src/enterprise-logs/vendor/github.com/grafana/loki/v3/pkg/iter/v2/iter.go", 332 }, 333 ref: "HEAD", 334 repo: "enterprise-logs", 335 mockFiles: []mockFileResponse{ 336 { 337 request: client.FileRequest{ 338 Owner: "grafana", 339 Repo: "enterprise-logs", 340 Ref: "HEAD", 341 Path: "vendor/github.com/grafana/loki/v3/pkg/iter/v2/iter.go", 342 }, 343 content: "# CONTENT iter.go", 344 }, 345 }, 346 expectedContent: "# CONTENT iter.go", 347 expectedURL: "https://github.com/grafana/enterprise-logs/blob/HEAD/vendor/github.com/grafana/loki/v3/pkg/iter/v2/iter.go", 348 expectedError: false, 349 }, 350 { 351 name: "go/not-mapped-stdlib", 352 fileSpec: config.FileSpec{ 353 FunctionName: "bufio.(*Reader).ReadSlice", 354 Path: "/usr/local/go/src/bufio/bufio.go", 355 }, 356 mockFiles: []mockFileResponse{ 357 { 358 request: client.FileRequest{ 359 Owner: "golang", 360 Repo: "go", 361 Ref: "master", 362 Path: "src/bufio/bufio.go", 363 }, 364 content: "# CONTENT bufio.go", 365 }, 366 }, 367 expectedContent: "# CONTENT bufio.go", 368 expectedURL: "https://github.com/golang/go/blob/master/src/bufio/bufio.go", 369 expectedError: false, 370 }, 371 { 372 name: "go/mapped-stdlib", 373 fileSpec: config.FileSpec{ 374 FunctionName: "bufio.(*Reader).ReadSlice", 375 Path: "/usr/local/go/src/bufio/bufio.go", 376 }, 377 pyroscopeYAML: goPyroscopeYAML, 378 mockFiles: []mockFileResponse{ 379 { 380 request: client.FileRequest{ 381 Owner: "golang", 382 Repo: "go", 383 Ref: "go1.24.8", 384 Path: "src/bufio/bufio.go", 385 }, 386 content: "# CONTENT bufio.go", 387 }, 388 }, 389 expectedContent: "# CONTENT bufio.go", 390 expectedURL: "https://github.com/golang/go/blob/go1.24.8/src/bufio/bufio.go", 391 expectedError: false, 392 }, 393 { 394 name: "go/mapped-dependency-bazel", 395 fileSpec: config.FileSpec{ 396 FunctionName: "github.com/stretchr/testify/require.NoError", 397 Path: "external/gazelle++go_deps+com_github_stretchr_testify/require/require.go", 398 }, 399 pyroscopeYAML: goPyroscopeYAMLBazel, 400 owner: "bazel-contrib", 401 repo: "rules_go", 402 rootPath: "examples/basic_gazelle", 403 ref: "v0.59.0", 404 405 mockFiles: []mockFileResponse{ 406 { 407 request: client.FileRequest{ 408 Owner: "stretchr", 409 Repo: "testify", 410 Ref: "v1.10.0", 411 Path: "require/require.go", 412 }, 413 content: "# CONTENT require.go", 414 }, 415 }, 416 expectedContent: "# CONTENT require.go", 417 expectedURL: "https://github.com/stretchr/testify/blob/v1.10.0/require/require.go", 418 expectedError: false, 419 }, 420 { 421 name: "python/stdlib", 422 fileSpec: config.FileSpec{ 423 FunctionName: "difflib.SequenceMatcher.find_longest_match", 424 Path: "/usr/lib/python3.12/difflib.py", 425 }, 426 ref: "main", 427 mockFiles: []mockFileResponse{ 428 { 429 request: client.FileRequest{ 430 Owner: "python", 431 Repo: "cpython", 432 Ref: "3.12", 433 Path: "Lib/difflib.py", 434 }, 435 content: "# CONTENT difflib.py", 436 }, 437 }, 438 expectedContent: "# CONTENT difflib.py", 439 expectedURL: "https://github.com/python/cpython/blob/3.12/Lib/difflib.py", 440 expectedError: false, 441 }, 442 { 443 name: "python/mapped-local-path", 444 fileSpec: config.FileSpec{ 445 FunctionName: "myproject.main.run", 446 Path: "/app/myproject/module/main.py", 447 }, 448 rootPath: "examples/python-app", 449 ref: "main", 450 pyroscopeYAML: pythonPyroscopeYAML, 451 mockFiles: []mockFileResponse{ 452 { 453 request: client.FileRequest{ 454 Owner: "grafana", 455 Repo: "pyroscope", 456 Ref: "main", 457 Path: "examples/python-app/src/module/main.py", 458 }, 459 content: "# CONTENT main.py", 460 }, 461 }, 462 expectedContent: "# CONTENT main.py", 463 expectedURL: "https://github.com/grafana/pyroscope/blob/main/examples/python-app/src/module/main.py", 464 expectedError: false, 465 }, 466 { 467 name: "python/relative-path", 468 fileSpec: config.FileSpec{ 469 FunctionName: "ListRecommendations", 470 Path: "recommendation_server.py", 471 }, 472 rootPath: "examples/python-app", 473 ref: "main", 474 mockFiles: []mockFileResponse{ 475 { 476 request: client.FileRequest{ 477 Owner: "grafana", 478 Repo: "pyroscope", 479 Ref: "main", 480 Path: "examples/python-app/recommendation_server.py", 481 }, 482 content: "# CONTENT recommendation_server.py", 483 }, 484 }, 485 expectedContent: "# CONTENT recommendation_server.py", 486 expectedURL: "https://github.com/grafana/pyroscope/blob/main/examples/python-app/recommendation_server.py", 487 expectedError: false, 488 }, 489 { 490 name: "fallback/unknown-file-extension", 491 fileSpec: config.FileSpec{ 492 FunctionName: "some.function", 493 Path: "scripts/example.unknown_extension", 494 }, 495 ref: "main", 496 mockFiles: []mockFileResponse{ 497 { 498 request: client.FileRequest{ 499 Owner: "grafana", 500 Repo: "pyroscope", 501 Ref: "main", 502 Path: "scripts/example.unknown_extension", 503 }, 504 content: "# Python script content\nprint('hello')", 505 }, 506 }, 507 expectedContent: "# Python script content\nprint('hello')", 508 expectedURL: "https://github.com/grafana/pyroscope/blob/main/scripts/example.unknown_extension", 509 expectedError: false, 510 }, 511 } 512 513 for _, tt := range tests { 514 t.Run(tt.name, func(t *testing.T) { 515 ctx := context.Background() 516 517 // Setup mock VCS client 518 mockClient := newMockVCSClient() 519 520 // Populate pyroscopeYAML content into first mock file (if present) 521 mockFiles := tt.mockFiles 522 if tt.pyroscopeYAML != "" { 523 mockFiles = append(mockFiles, mockFileResponse{ 524 request: client.FileRequest{ 525 Owner: tt.owner, 526 Repo: tt.repo, 527 Ref: tt.ref, 528 Path: filepath.Join(tt.rootPath, ".pyroscope.yaml"), 529 }, 530 content: tt.pyroscopeYAML, 531 }) 532 } 533 mockClient.addFiles(mockFiles...) 534 535 // Setup repository URL 536 repoURL, err := giturl.NewGitURL(fmt.Sprintf("https://github.com/%s/%s", defaultOwner(tt.owner), defaultRepo(tt.repo))) 537 require.NoError(t, err) 538 539 // Create HTTP client 540 httpClient := &http.Client{} 541 542 // Create FileFinder 543 finder := NewFileFinder( 544 mockClient, 545 repoURL, 546 tt.fileSpec, 547 tt.rootPath, 548 defaultRef(tt.ref), 549 httpClient, 550 log.NewNopLogger(), 551 ) 552 553 // Execute the Find method 554 response, err := finder.Find(ctx) 555 556 // Assertions 557 if tt.expectedError { 558 require.Error(t, err) 559 } else { 560 require.NoError(t, err, "Find should succeed") 561 require.NotNil(t, response, "Response should not be nil") 562 563 // Decode and verify content 564 decodedContent, err := base64.StdEncoding.DecodeString(response.Content) 565 require.NoError(t, err, "Content should be valid base64") 566 assert.Equal(t, tt.expectedContent, string(decodedContent), "Content should match expected file") 567 568 // Verify URL 569 assert.Equal(t, tt.expectedURL, response.URL, "URL should point to correct location") 570 } 571 }) 572 } 573 } 574 575 // TestFileFinder_Find_FileNotFound tests that Find returns client.ErrNotFound when files are not found 576 func TestFileFinder_Find_FileNotFound(t *testing.T) { 577 tests := []struct { 578 name string 579 fileSpec config.FileSpec 580 rootPath string 581 ref string 582 pyroscopeYAML string 583 }{ 584 { 585 name: "fallback/file-not-found", 586 fileSpec: config.FileSpec{ 587 FunctionName: "some.function", 588 Path: "nonexistent/file.txt", 589 }, 590 ref: "main", 591 }, 592 { 593 name: "go/local-file-not-found", 594 fileSpec: config.FileSpec{ 595 FunctionName: "github.com/grafana/pyroscope/pkg/foo.Bar", 596 Path: "/Users/christian/git/github.com/grafana/pyroscope/pkg/foo/bar.go", 597 }, 598 ref: "main", 599 }, 600 { 601 name: "go/stdlib-not-found", 602 fileSpec: config.FileSpec{ 603 FunctionName: "bufio.(*Reader).ReadSlice", 604 Path: "/usr/local/go/src/bufio/bufio.go", 605 }, 606 ref: "main", 607 }, 608 { 609 name: "python/stdlib-not-found", 610 fileSpec: config.FileSpec{ 611 FunctionName: "difflib.SequenceMatcher.find_longest_match", 612 Path: "/usr/lib/python3.12/difflib.py", 613 }, 614 ref: "main", 615 }, 616 { 617 name: "python/no-stdlib-no-mappings", 618 fileSpec: config.FileSpec{ 619 FunctionName: "myapp.module.function", 620 Path: "/app/myapp/module.py", 621 }, 622 ref: "main", 623 }, 624 { 625 name: "python/mappings-file-not-found", 626 fileSpec: config.FileSpec{ 627 FunctionName: "myproject.main.run", 628 Path: "/app/myproject/module/main.py", 629 }, 630 rootPath: "examples/python-app", 631 ref: "main", 632 pyroscopeYAML: pythonPyroscopeYAML, 633 }, 634 { 635 name: "java/no-mappings", 636 fileSpec: config.FileSpec{ 637 FunctionName: "org/example/MyClass.myMethod", 638 Path: "", 639 }, 640 ref: "main", 641 pyroscopeYAML: `--- 642 source_code: 643 mappings: 644 - function_name: 645 - prefix: org/example 646 language: java 647 source: 648 local: 649 path: src/main/java 650 `, 651 }, 652 { 653 name: "go/dependency-not-found", 654 fileSpec: config.FileSpec{ 655 FunctionName: "github.com/parquet-go/parquet-go.(*bufferPool).newBuffer", 656 Path: "/Users/christian/.golang/packages/pkg/mod/github.com/parquet-go/parquet-go@v0.23.0/buffer.go", 657 }, 658 ref: "main", 659 }, 660 } 661 662 for _, tt := range tests { 663 t.Run(tt.name, func(t *testing.T) { 664 ctx := context.Background() 665 666 // Setup mock VCS client with no files (everything returns ErrNotFound) 667 mockClient := newMockVCSClient() 668 669 // Add pyroscope.yaml if provided (but no actual source files) 670 if tt.pyroscopeYAML != "" { 671 mockClient.addFiles(mockFileResponse{ 672 request: client.FileRequest{ 673 Ref: tt.ref, 674 Path: filepath.Join(tt.rootPath, ".pyroscope.yaml"), 675 }, 676 content: tt.pyroscopeYAML, 677 }) 678 } 679 680 // Setup repository URL 681 repoURL, err := giturl.NewGitURL("https://github.com/grafana/pyroscope") 682 require.NoError(t, err) 683 684 // Create FileFinder 685 finder := NewFileFinder( 686 mockClient, 687 repoURL, 688 tt.fileSpec, 689 tt.rootPath, 690 defaultRef(tt.ref), 691 &http.Client{}, 692 log.NewNopLogger(), 693 ) 694 695 // Execute the Find method 696 response, err := finder.Find(ctx) 697 698 // Assertions 699 require.Error(t, err, "Find should return an error when file is not found") 700 701 // Check if error is a connect error with CodeNotFound 702 var connectErr *connect.Error 703 if errors.As(err, &connectErr) { 704 require.Equal(t, connect.CodeNotFound, connectErr.Code(), "Connect error should have CodeNotFound") 705 } else { 706 // Fallback check for client.ErrNotFound 707 require.ErrorIs(t, err, client.ErrNotFound, "Error should be client.ErrNotFound") 708 } 709 require.Nil(t, response, "Response should be nil when file is not found") 710 }) 711 } 712 }