github.com/grafana/pyroscope@v1.18.0/cmd/profilecli/source_code_coverage_test.go (about) 1 package main 2 3 import ( 4 "context" 5 "net/http" 6 "os" 7 "path/filepath" 8 "testing" 9 "time" 10 11 "github.com/go-kit/log" 12 "github.com/stretchr/testify/require" 13 14 profilev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1" 15 "github.com/grafana/pyroscope/pkg/frontend/vcs/client" 16 "github.com/grafana/pyroscope/pkg/frontend/vcs/config" 17 "github.com/grafana/pyroscope/pkg/pprof" 18 "github.com/grafana/pyroscope/pkg/pprof/testhelper" 19 ) 20 21 func TestExtractFunctions(t *testing.T) { 22 tests := []struct { 23 name string 24 profile *profilev1.Profile 25 expected []config.FileSpec 26 }{ 27 { 28 name: "extract functions with names and paths", 29 profile: &profilev1.Profile{ 30 StringTable: []string{"", "main", "foo", "bar", "/path/to/main.go", "/path/to/bar.go"}, 31 Function: []*profilev1.Function{ 32 {Id: 1, Name: 1, Filename: 4}, // main in /path/to/main.go 33 {Id: 2, Name: 2, Filename: 4}, // foo in /path/to/main.go 34 {Id: 3, Name: 3, Filename: 5}, // bar in /path/to/bar.go 35 }, 36 }, 37 expected: []config.FileSpec{ 38 {FunctionName: "main", Path: "/path/to/main.go"}, 39 {FunctionName: "foo", Path: "/path/to/main.go"}, 40 {FunctionName: "bar", Path: "/path/to/bar.go"}, 41 }, 42 }, 43 { 44 name: "skip functions with no name or path", 45 profile: &profilev1.Profile{ 46 StringTable: []string{"", "main", "/path/to/main.go"}, 47 Function: []*profilev1.Function{ 48 {Id: 1, Name: 1, Filename: 2}, // main in /path/to/main.go 49 {Id: 2, Name: 0, Filename: 0}, // no name or path - should be skipped 50 }, 51 }, 52 expected: []config.FileSpec{ 53 {FunctionName: "main", Path: "/path/to/main.go"}, 54 }, 55 }, 56 { 57 name: "deduplicate functions", 58 profile: &profilev1.Profile{ 59 StringTable: []string{"", "main", "/path/to/main.go"}, 60 Function: []*profilev1.Function{ 61 {Id: 1, Name: 1, Filename: 2}, // main in /path/to/main.go 62 {Id: 2, Name: 1, Filename: 2}, // duplicate - should be skipped 63 }, 64 }, 65 expected: []config.FileSpec{ 66 {FunctionName: "main", Path: "/path/to/main.go"}, 67 }, 68 }, 69 } 70 71 for _, tt := range tests { 72 t.Run(tt.name, func(t *testing.T) { 73 result := extractFunctions(tt.profile) 74 require.Equal(t, len(tt.expected), len(result)) 75 for i, expected := range tt.expected { 76 require.Equal(t, expected.FunctionName, result[i].FunctionName) 77 require.Equal(t, expected.Path, result[i].Path) 78 } 79 }) 80 } 81 } 82 83 func TestCalculateSampleCountsMap(t *testing.T) { 84 profile := &profilev1.Profile{ 85 StringTable: []string{"", "main", "foo", "/path/to/main.go", "/path/to/foo.go"}, 86 Function: []*profilev1.Function{ 87 {Id: 1, Name: 1, Filename: 3}, // main in /path/to/main.go 88 {Id: 2, Name: 2, Filename: 4}, // foo in /path/to/foo.go 89 }, 90 Location: []*profilev1.Location{ 91 {Id: 1, Line: []*profilev1.Line{{FunctionId: 1, Line: 10}}}, 92 {Id: 2, Line: []*profilev1.Line{{FunctionId: 2, Line: 20}}}, 93 }, 94 Sample: []*profilev1.Sample{ 95 {LocationId: []uint64{1}, Value: []int64{5}}, // 5 samples for main 96 {LocationId: []uint64{1}, Value: []int64{3}}, // 3 more samples for main 97 {LocationId: []uint64{2}, Value: []int64{2}}, // 2 samples for foo 98 {LocationId: []uint64{1, 2}, Value: []int64{1}}, // 1 sample for both (should count both) 99 }, 100 } 101 102 result := calculateSampleCountsMap(profile) 103 104 require.Equal(t, int64(9), result["main|/path/to/main.go"]) // 5 + 3 + 1 105 require.Equal(t, int64(3), result["foo|/path/to/foo.go"]) // 2 + 1 106 } 107 108 func TestGenerateOutput(t *testing.T) { 109 report := &coverageReport{ 110 TotalFunctions: 10, 111 CoveredFunctions: 7, 112 UncoveredFunctions: 3, 113 CoveragePercentage: 70.0, 114 Results: []functionResult{ 115 {FunctionName: "main", Path: "/main.go", Covered: true, SampleCount: 100}, 116 {FunctionName: "foo", Path: "/foo.go", Covered: false, SampleCount: 50}, 117 }, 118 } 119 120 t.Run("text format", func(t *testing.T) { 121 // Capture stdout 122 oldStdout := os.Stdout 123 r, w, _ := os.Pipe() 124 os.Stdout = w 125 126 err := generateOutput(report, "text") 127 require.NoError(t, err) 128 129 w.Close() 130 os.Stdout = oldStdout 131 132 output := make([]byte, 1024) 133 n, _ := r.Read(output) 134 outputStr := string(output[:n]) 135 136 require.Contains(t, outputStr, "Coverage Summary") 137 require.Contains(t, outputStr, "Total Functions: 10") 138 require.Contains(t, outputStr, "Covered Functions: 7") 139 require.Contains(t, outputStr, "Coverage: 70.00%") 140 }) 141 142 t.Run("detailed format", func(t *testing.T) { 143 oldStdout := os.Stdout 144 r, w, _ := os.Pipe() 145 os.Stdout = w 146 147 err := generateOutput(report, "detailed") 148 require.NoError(t, err) 149 150 w.Close() 151 os.Stdout = oldStdout 152 153 output := make([]byte, 1024) 154 n, _ := r.Read(output) 155 outputStr := string(output[:n]) 156 157 require.Contains(t, outputStr, "Detailed Results") 158 require.Contains(t, outputStr, "main") 159 require.Contains(t, outputStr, "foo") 160 }) 161 162 t.Run("unknown format", func(t *testing.T) { 163 err := generateOutput(report, "unknown") 164 require.Error(t, err) 165 require.Contains(t, err.Error(), "unknown output format") 166 }) 167 } 168 169 func TestListAllFunctions(t *testing.T) { 170 // Create a temporary profile file 171 builder := testhelper.NewProfileBuilder(1000). 172 CPUProfile(). 173 ForStacktraceString("main", "foo", "bar").AddSamples(10) 174 175 profileBytes, err := builder.MarshalVT() 176 require.NoError(t, err) 177 178 tmpFile, err := os.CreateTemp("", "test-profile-*.pprof") 179 require.NoError(t, err) 180 defer os.Remove(tmpFile.Name()) 181 182 _, err = tmpFile.Write(profileBytes) 183 require.NoError(t, err) 184 tmpFile.Close() 185 186 t.Run("text output", func(t *testing.T) { 187 oldStdout := os.Stdout 188 r, w, _ := os.Pipe() 189 os.Stdout = w 190 191 err := listAllFunctions(tmpFile.Name()) 192 require.NoError(t, err) 193 194 w.Close() 195 os.Stdout = oldStdout 196 197 output := make([]byte, 1024) 198 n, _ := r.Read(output) 199 outputStr := string(output[:n]) 200 201 require.Contains(t, outputStr, "Functions in Profile") 202 require.Contains(t, outputStr, "Total:") 203 }) 204 205 t.Run("invalid profile file", func(t *testing.T) { 206 err := listAllFunctions("/nonexistent/file.pprof") 207 require.Error(t, err) 208 }) 209 } 210 211 func TestCoverageReportSortBySampleCount(t *testing.T) { 212 report := &coverageReport{ 213 Results: []functionResult{ 214 {FunctionName: "low", SampleCount: 10}, 215 {FunctionName: "high", SampleCount: 100}, 216 {FunctionName: "medium", SampleCount: 50}, 217 }, 218 } 219 220 report.sortBySampleCount() 221 222 require.Equal(t, "high", report.Results[0].FunctionName) 223 require.Equal(t, int64(100), report.Results[0].SampleCount) 224 require.Equal(t, "medium", report.Results[1].FunctionName) 225 require.Equal(t, int64(50), report.Results[1].SampleCount) 226 require.Equal(t, "low", report.Results[2].FunctionName) 227 require.Equal(t, int64(10), report.Results[2].SampleCount) 228 } 229 230 func TestHybridVCSClient(t *testing.T) { 231 configContent := []byte("source_code:\n mappings: []") 232 configPath := ".pyroscope.yaml" 233 234 mockClient := &mockVCSClient{} 235 hybridClient := &hybridVCSClient{ 236 configContent: configContent, 237 configPath: configPath, 238 realClient: mockClient, 239 } 240 241 t.Run("intercepts config file requests", func(t *testing.T) { 242 req := client.FileRequest{ 243 Owner: "test", 244 Repo: "repo", 245 Ref: "main", 246 Path: configPath, 247 } 248 249 file, err := hybridClient.GetFile(context.Background(), req) 250 require.NoError(t, err) 251 require.Equal(t, string(configContent), file.Content) 252 }) 253 254 t.Run("delegates to real client for source files", func(t *testing.T) { 255 req := client.FileRequest{ 256 Owner: "test", 257 Repo: "repo", 258 Ref: "main", 259 Path: "src/main.go", 260 } 261 262 file, err := hybridClient.GetFile(context.Background(), req) 263 require.NoError(t, err) 264 require.Equal(t, "mock content", file.Content) 265 require.Equal(t, "https://github.com/test/repo/blob/main/src/main.go", file.URL) 266 }) 267 } 268 269 type mockVCSClient struct{} 270 271 func (m *mockVCSClient) GetFile(ctx context.Context, req client.FileRequest) (client.File, error) { 272 return client.File{ 273 Content: "mock content", 274 URL: "https://github.com/" + req.Owner + "/" + req.Repo + "/blob/" + req.Ref + "/" + req.Path, 275 }, nil 276 } 277 278 func TestOutputSingleFunctionResults(t *testing.T) { 279 results := []functionResult{ 280 { 281 FunctionName: "testFunc", 282 Path: "/test.go", 283 Covered: true, 284 ResolvedURL: "https://github.com/test/repo/blob/main/test.go", 285 SampleCount: 100, 286 }, 287 } 288 289 t.Run("text output", func(t *testing.T) { 290 oldStdout := os.Stdout 291 r, w, _ := os.Pipe() 292 os.Stdout = w 293 294 err := outputSingleFunctionResults(results) 295 require.NoError(t, err) 296 297 w.Close() 298 os.Stdout = oldStdout 299 300 output := make([]byte, 1024) 301 n, _ := r.Read(output) 302 outputStr := string(output[:n]) 303 304 require.Contains(t, outputStr, "Function Coverage") 305 require.Contains(t, outputStr, "testFunc") 306 require.Contains(t, outputStr, "/test.go") 307 require.Contains(t, outputStr, "Covered: true") 308 }) 309 310 } 311 312 func TestAnalyzeCoverage(t *testing.T) { 313 builder := testhelper.NewProfileBuilder(1000). 314 CPUProfile(). 315 ForStacktraceString("main", "foo").AddSamples(10) 316 317 profileBytes, err := builder.MarshalVT() 318 require.NoError(t, err) 319 320 profile, err := pprof.RawFromBytes(profileBytes) 321 require.NoError(t, err) 322 323 cfg := &config.PyroscopeConfig{ 324 SourceCode: config.SourceCodeConfig{ 325 Mappings: []config.MappingConfig{}, 326 }, 327 } 328 329 mockClient := &mockVCSClient{} 330 331 functions := extractFunctions(profile.Profile) 332 require.Equal(t, len(functions), 2) 333 334 logger := log.NewNopLogger() 335 httpClient := &http.Client{Timeout: 30 * time.Second} 336 report := analyzeCoverage( 337 context.Background(), 338 profile.Profile, 339 functions, 340 cfg, 341 mockClient, 342 httpClient, 343 logger, 344 ) 345 346 require.Equal(t, len(functions), report.TotalFunctions) 347 require.Equal(t, report.CoveredFunctions, 0) 348 // No mappings in config 349 require.Equal(t, report.UncoveredFunctions, 2) 350 require.Equal(t, len(functions), len(report.Results)) 351 } 352 353 func TestRunCoverageAnalysis_InvalidProfile(t *testing.T) { 354 tmpDir := t.TempDir() 355 configPath := filepath.Join(tmpDir, ".pyroscope.yaml") 356 profilePath := filepath.Join(tmpDir, "nonexistent.pprof") 357 358 configContent := `source_code: 359 mappings: []` 360 err := os.WriteFile(configPath, []byte(configContent), 0644) 361 require.NoError(t, err) 362 363 params := &sourceCodeCoverageParams{ 364 ProfilePath: profilePath, 365 ConfigPath: configPath, 366 GithubToken: "test-token", 367 OutputFormat: "text", 368 TopN: 0, 369 } 370 371 err = runCoverageAnalysis(context.Background(), params) 372 require.Error(t, err) 373 require.Contains(t, err.Error(), "failed to read profile") 374 } 375 376 func TestRunCoverageAnalysis_InvalidConfig(t *testing.T) { 377 tmpDir := t.TempDir() 378 configPath := filepath.Join(tmpDir, ".pyroscope.yaml") 379 profilePath := filepath.Join(tmpDir, "test.pprof") 380 381 // Create invalid config file 382 err := os.WriteFile(configPath, []byte("invalid yaml: [["), 0644) 383 require.NoError(t, err) 384 385 builder := testhelper.NewProfileBuilder(1000).CPUProfile() 386 profileBytes, err := builder.MarshalVT() 387 require.NoError(t, err) 388 err = os.WriteFile(profilePath, profileBytes, 0644) 389 require.NoError(t, err) 390 391 params := &sourceCodeCoverageParams{ 392 ProfilePath: profilePath, 393 ConfigPath: configPath, 394 GithubToken: "test-token", 395 OutputFormat: "text", 396 TopN: 0, 397 } 398 399 err = runCoverageAnalysis(context.Background(), params) 400 require.Error(t, err) 401 require.Contains(t, err.Error(), "failed to parse config") 402 } 403 404 func TestSourceCodeCoverage_ListFunctionsMode(t *testing.T) { 405 builder := testhelper.NewProfileBuilder(1000). 406 CPUProfile(). 407 ForStacktraceString("main", "foo").AddSamples(10) 408 409 profileBytes, err := builder.MarshalVT() 410 require.NoError(t, err) 411 412 tmpFile, err := os.CreateTemp("", "test-profile-*.pprof") 413 require.NoError(t, err) 414 defer os.Remove(tmpFile.Name()) 415 416 _, err = tmpFile.Write(profileBytes) 417 require.NoError(t, err) 418 tmpFile.Close() 419 420 params := &sourceCodeCoverageParams{ 421 ProfilePath: tmpFile.Name(), 422 ListFunctions: true, 423 OutputFormat: "text", 424 } 425 426 err = sourceCodeCoverage(context.Background(), params) 427 require.NoError(t, err) 428 } 429 430 func TestSourceCodeCoverage_ValidationErrors(t *testing.T) { 431 t.Run("missing config and repo for function check", func(t *testing.T) { 432 params := &sourceCodeCoverageParams{ 433 ProfilePath: "test.pprof", 434 FunctionName: "testFunc", 435 } 436 437 err := sourceCodeCoverage(context.Background(), params) 438 require.Error(t, err) 439 require.Contains(t, err.Error(), "--config is required") 440 }) 441 } 442 443 func TestOutputDetailed_ShowsErrors(t *testing.T) { 444 report := &coverageReport{ 445 Results: []functionResult{ 446 { 447 FunctionName: "func1", 448 Path: "/path/to/func1.go", 449 Covered: false, 450 Error: "file not found", 451 SampleCount: 10, 452 }, 453 { 454 FunctionName: "func2", 455 Path: "/path/to/func2.go", 456 Covered: true, 457 ResolvedURL: "https://github.com/test/repo/blob/main/path/to/func2.go", 458 SampleCount: 20, 459 }, 460 }, 461 } 462 463 oldStdout := os.Stdout 464 r, w, _ := os.Pipe() 465 os.Stdout = w 466 467 outputDetailed(report) 468 469 w.Close() 470 os.Stdout = oldStdout 471 472 output := make([]byte, 2048) 473 n, _ := r.Read(output) 474 outputStr := string(output[:n]) 475 476 // Errors should always be shown 477 require.Contains(t, outputStr, "file not found") 478 require.Contains(t, outputStr, "func1") 479 require.Contains(t, outputStr, "func2") 480 require.Contains(t, outputStr, "URL:") 481 } 482 483 func TestCheckSingleFunction_NoMatch(t *testing.T) { 484 tmpDir := t.TempDir() 485 configPath := filepath.Join(tmpDir, ".pyroscope.yaml") 486 profilePath := filepath.Join(tmpDir, "test.pprof") 487 488 configContent := `source_code: 489 mappings: []` 490 err := os.WriteFile(configPath, []byte(configContent), 0644) 491 require.NoError(t, err) 492 493 builder := testhelper.NewProfileBuilder(1000).CPUProfile() 494 profileBytes, err := builder.MarshalVT() 495 require.NoError(t, err) 496 err = os.WriteFile(profilePath, profileBytes, 0644) 497 require.NoError(t, err) 498 499 params := &sourceCodeCoverageParams{ 500 ProfilePath: profilePath, 501 ConfigPath: configPath, 502 FunctionName: "nonexistentFunction", 503 GithubToken: "test-token", 504 OutputFormat: "text", 505 } 506 507 err = checkSingleFunction(context.Background(), params) 508 require.Error(t, err) 509 require.Contains(t, err.Error(), "no function found matching") 510 } 511 512 func TestExtractFunctions_EdgeCases(t *testing.T) { 513 t.Run("empty profile", func(t *testing.T) { 514 profile := &profilev1.Profile{ 515 StringTable: []string{""}, 516 Function: []*profilev1.Function{}, 517 } 518 result := extractFunctions(profile) 519 require.Empty(t, result) 520 }) 521 522 t.Run("function with only name", func(t *testing.T) { 523 profile := &profilev1.Profile{ 524 StringTable: []string{"", "main"}, 525 Function: []*profilev1.Function{ 526 {Id: 1, Name: 1, Filename: 0}, 527 }, 528 } 529 result := extractFunctions(profile) 530 require.Len(t, result, 1) 531 require.Equal(t, "main", result[0].FunctionName) 532 require.Equal(t, "", result[0].Path) 533 }) 534 535 t.Run("function with only path", func(t *testing.T) { 536 profile := &profilev1.Profile{ 537 StringTable: []string{"", "/path/to/file.go"}, 538 Function: []*profilev1.Function{ 539 {Id: 1, Name: 0, Filename: 1}, 540 }, 541 } 542 result := extractFunctions(profile) 543 require.Len(t, result, 1) 544 require.Equal(t, "", result[0].FunctionName) 545 require.Equal(t, "/path/to/file.go", result[0].Path) 546 }) 547 } 548 549 func TestCalculateSampleCountsMap_EdgeCases(t *testing.T) { 550 t.Run("empty samples", func(t *testing.T) { 551 profile := &profilev1.Profile{ 552 StringTable: []string{""}, 553 Function: []*profilev1.Function{}, 554 Location: []*profilev1.Location{}, 555 Sample: []*profilev1.Sample{}, 556 } 557 result := calculateSampleCountsMap(profile) 558 require.Empty(t, result) 559 }) 560 561 t.Run("sample with zero value", func(t *testing.T) { 562 profile := &profilev1.Profile{ 563 StringTable: []string{"", "main", "/main.go"}, 564 Function: []*profilev1.Function{ 565 {Id: 1, Name: 1, Filename: 2}, 566 }, 567 Location: []*profilev1.Location{ 568 {Id: 1, Line: []*profilev1.Line{{FunctionId: 1, Line: 10}}}, 569 }, 570 Sample: []*profilev1.Sample{ 571 {LocationId: []uint64{1}, Value: []int64{0}}, // Zero value - should be skipped 572 }, 573 } 574 result := calculateSampleCountsMap(profile) 575 require.Empty(t, result) 576 }) 577 578 t.Run("multiple sample types", func(t *testing.T) { 579 profile := &profilev1.Profile{ 580 StringTable: []string{"", "main", "/main.go"}, 581 Function: []*profilev1.Function{ 582 {Id: 1, Name: 1, Filename: 2}, 583 }, 584 Location: []*profilev1.Location{ 585 {Id: 1, Line: []*profilev1.Line{{FunctionId: 1, Line: 10}}}, 586 }, 587 Sample: []*profilev1.Sample{ 588 {LocationId: []uint64{1}, Value: []int64{5, 10}}, // Multiple values - should sum 589 }, 590 } 591 result := calculateSampleCountsMap(profile) 592 require.Equal(t, int64(15), result["main|/main.go"]) // 5 + 10 593 }) 594 }