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  }