aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTaras Madan <tarasmadan@google.com>2025-01-09 15:45:40 +0100
committerTaras Madan <tarasmadan@google.com>2025-01-27 10:05:21 +0000
commit0868754a9d325ba9011e1cb74510f68d4b627c79 (patch)
treece1f63a84afa095227d308ccfff258e2d1dea225
parentd99a33ad01eb09190a8680d743f8d520e459ef0f (diff)
dashboard/app: show manager unique coverage
1. Make heatmap testable, move out the spanner client instantiation. 2. Generate spannerdb.ReadOnlyTransaction mocks. 3. Generate spannerdb.RowIterator mocks. 4. Generate spannerdb.Row mocks. 5. Prepare spannerdb fixture. 6. Fixed html control name + value. 7. Added multiple tests. 8. Show line coverage from selected manager. 9. Propagate coverage url params to file coverage url.
-rw-r--r--dashboard/app/coverage.go36
-rw-r--r--dashboard/app/static/coverage.js7
-rw-r--r--pkg/cover/file.go2
-rw-r--r--pkg/cover/heatmap.go206
-rw-r--r--pkg/cover/heatmap_test.go240
-rw-r--r--pkg/cover/templates/heatmap.html4
-rw-r--r--pkg/coveragedb/coveragedb.go33
-rw-r--r--pkg/coveragedb/coveragedb_mock_test.go3
-rw-r--r--pkg/coveragedb/mocks/ReadOnlyTransaction.go51
-rw-r--r--pkg/coveragedb/mocks/Row.go42
-rw-r--r--pkg/coveragedb/mocks/RowIterator.go62
-rw-r--r--pkg/covermerger/covermerger.go6
-rw-r--r--pkg/covermerger/file_line_merger.go4
-rw-r--r--pkg/validator/validator.go21
-rw-r--r--pkg/validator/validator_test.go18
15 files changed, 673 insertions, 62 deletions
diff --git a/dashboard/app/coverage.go b/dashboard/app/coverage.go
index e0ceab563..7bc0d2a06 100644
--- a/dashboard/app/coverage.go
+++ b/dashboard/app/coverage.go
@@ -14,11 +14,14 @@ import (
"cloud.google.com/go/civil"
"github.com/google/syzkaller/pkg/cover"
"github.com/google/syzkaller/pkg/coveragedb"
+ "github.com/google/syzkaller/pkg/coveragedb/spannerclient"
"github.com/google/syzkaller/pkg/covermerger"
"github.com/google/syzkaller/pkg/validator"
)
-type funcStyleBodyJS func(ctx context.Context, projectID string, scope *cover.SelectScope, sss, managers []string,
+type funcStyleBodyJS func(
+ ctx context.Context, client spannerclient.SpannerClient,
+ scope *cover.SelectScope, onlyUnique bool, sss, managers []string,
) (template.CSS, template.HTML, template.HTML, error)
func handleCoverageHeatmap(c context.Context, w http.ResponseWriter, r *http.Request) error {
@@ -71,16 +74,24 @@ func handleHeatmap(c context.Context, w http.ResponseWriter, r *http.Request, f
slices.Sort(managers)
slices.Sort(subsystems)
+ onlyUnique := r.FormValue("unique-only") == "1"
+
+ spannerClient, err := spannerclient.NewClient(c, "syzkaller")
+ if err != nil {
+ return fmt.Errorf("spanner.NewClient: %s", err.Error())
+ }
+ defer spannerClient.Close()
+
var style template.CSS
var body, js template.HTML
- if style, body, js, err = f(c, "syzkaller",
+ if style, body, js, err = f(c, spannerClient,
&cover.SelectScope{
Ns: hdr.Namespace,
Subsystem: ss,
Manager: manager,
Periods: periods,
},
- subsystems, managers); err != nil {
+ onlyUnique, subsystems, managers); err != nil {
return fmt.Errorf("failed to generate heatmap: %w", err)
}
return serveTemplate(w, "custom_content.html", struct {
@@ -115,10 +126,14 @@ func handleFileCoverage(c context.Context, w http.ResponseWriter, r *http.Reques
periodType := r.FormValue("period")
targetCommit := r.FormValue("commit")
kernelFilePath := r.FormValue("filepath")
+ manager := r.FormValue("manager")
if err := validator.AnyError("input validation failed",
validator.TimePeriodType(periodType, "period"),
validator.CommitHash(targetCommit, "commit"),
validator.KernelFilePath(kernelFilePath, "filepath"),
+ validator.AnyOk(
+ validator.Allowlisted(manager, []string{"", "*"}, "manager"),
+ validator.ManagerName(manager, "manager")),
); err != nil {
return fmt.Errorf("%w: %w", err, ErrClientBadRequest)
}
@@ -130,10 +145,19 @@ func handleFileCoverage(c context.Context, w http.ResponseWriter, r *http.Reques
if err != nil {
return fmt.Errorf("coveragedb.MakeTimePeriod: %w", err)
}
+ onlyUnique := r.FormValue("unique-only") == "1"
mainNsRepo, _ := nsConfig.mainRepoBranch()
- hitCounts, err := coveragedb.ReadLinesHitCount(c, hdr.Namespace, targetCommit, kernelFilePath, tp)
+ hitLines, hitCounts, err := coveragedb.ReadLinesHitCount(c, hdr.Namespace, targetCommit, manager, kernelFilePath, tp)
+ covMap := cover.MakeCovMap(hitLines, hitCounts)
if err != nil {
- return fmt.Errorf("coveragedb.ReadLinesHitCount: %w", err)
+ return fmt.Errorf("coveragedb.ReadLinesHitCount(%s): %w", manager, err)
+ }
+ if onlyUnique {
+ allHitLines, allHitCounts, err := coveragedb.ReadLinesHitCount(c, hdr.Namespace, targetCommit, manager, kernelFilePath, tp)
+ if err != nil {
+ return fmt.Errorf("coveragedb.ReadLinesHitCount(*): %w", err)
+ }
+ covMap = cover.UniqCoverage(cover.MakeCovMap(allHitLines, allHitCounts), covMap)
}
content, err := cover.RendFileCoverage(
@@ -141,7 +165,7 @@ func handleFileCoverage(c context.Context, w http.ResponseWriter, r *http.Reques
targetCommit,
kernelFilePath,
makeProxyURIProvider(nsConfig.Coverage.WebGitURI),
- &covermerger.MergeResult{HitCounts: hitCounts},
+ &covermerger.MergeResult{HitCounts: covMap},
cover.DefaultHTMLRenderConfig())
if err != nil {
return fmt.Errorf("cover.RendFileCoverage: %w", err)
diff --git a/dashboard/app/static/coverage.js b/dashboard/app/static/coverage.js
index 92fccc091..8a5190b90 100644
--- a/dashboard/app/static/coverage.js
+++ b/dashboard/app/static/coverage.js
@@ -20,7 +20,7 @@ function initUpdateForm(){
}
$('#target-subsystem').val(curUrlParams.get('subsystem'));
$('#target-manager').val(curUrlParams.get('manager'));
- $("#only-unique").prop("checked", curUrlParams.get('subsystem') == "1");
+ $("#unique-only").prop("checked", curUrlParams.get('unique-only') == "1");
}
// This handler is called when user clicks on the coverage percentage.
@@ -28,6 +28,11 @@ function initUpdateForm(){
// "#file-content-prev" and "#file-content-curr" are the file content <div>s.
// "#file-details-prev" and "#file-details-curr" are the corresponding <div>s used to show per-file details.
function onShowFileContent(url) {
+ var curUrlParams = new URLSearchParams(window.location.search);
+ url += '&subsystem=' + curUrlParams.get('subsystem')
+ url += '&manager=' + curUrlParams.get('manager')
+ url += '&unique-only=' + curUrlParams.get('unique-only')
+
$.get(url, function(response) {
$("#file-content-prev").html($("#file-content-curr").html());
$("#file-content-curr").html(response);
diff --git a/pkg/cover/file.go b/pkg/cover/file.go
index a0561dfe7..c56f88204 100644
--- a/pkg/cover/file.go
+++ b/pkg/cover/file.go
@@ -101,7 +101,7 @@ func GetMergeResult(c context.Context, ns, repo, forCommit, sourceCommit, filePa
func rendResult(content string, coverage *covermerger.MergeResult, renderConfig *CoverageRenderConfig) string {
if coverage == nil {
coverage = &covermerger.MergeResult{
- HitCounts: map[int]int{},
+ HitCounts: map[int]int64{},
LineDetails: map[int][]*covermerger.FileRecord{},
}
}
diff --git a/pkg/cover/heatmap.go b/pkg/cover/heatmap.go
index b8e3bbfa6..04b7e6914 100644
--- a/pkg/cover/heatmap.go
+++ b/pkg/cover/heatmap.go
@@ -17,6 +17,7 @@ import (
"github.com/google/syzkaller/pkg/coveragedb/spannerclient"
_ "github.com/google/syzkaller/pkg/subsystem/lists"
"golang.org/x/exp/maps"
+ "golang.org/x/sync/errgroup"
"google.golang.org/api/iterator"
)
@@ -115,6 +116,16 @@ type fileCoverageWithDetails struct {
Subsystems []string
}
+type fileCoverageWithLineInfo struct {
+ fileCoverageWithDetails
+ LinesInstrumented []int64
+ HitCounts []int64
+}
+
+func (fc *fileCoverageWithLineInfo) CovMap() map[int]int64 {
+ return MakeCovMap(fc.LinesInstrumented, fc.HitCounts)
+}
+
type pageColumnTarget struct {
TimePeriod coveragedb.TimePeriod
Commit string
@@ -157,18 +168,17 @@ func filesCoverageToTemplateData(fCov []*fileCoverageWithDetails) *templateHeatm
return &res
}
-func filesCoverageWithDetailsStmt(ns, subsystem, manager string, timePeriod coveragedb.TimePeriod) spanner.Statement {
+func filesCoverageWithDetailsStmt(ns, subsystem, manager string, timePeriod coveragedb.TimePeriod, withLines bool,
+) spanner.Statement {
if manager == "" {
manager = "*"
}
+ selectColumns := "commit, instrumented, covered, files.filepath, subsystems"
+ if withLines {
+ selectColumns += ", linesinstrumented, hitcounts"
+ }
stmt := spanner.Statement{
- SQL: `
-select
- commit,
- instrumented,
- covered,
- files.filepath,
- subsystems
+ SQL: "select " + selectColumns + `
from merge_history
join files
on merge_history.session = files.session
@@ -187,37 +197,171 @@ where
stmt.SQL += " and $5=ANY(subsystems)"
stmt.Params["p5"] = subsystem
}
+ stmt.SQL += "\norder by files.filepath"
return stmt
}
-func filesCoverageWithDetails(ctx context.Context, projectID string, scope *SelectScope,
-) ([]*fileCoverageWithDetails, error) {
- client, err := spannerclient.NewClient(ctx, projectID)
+func readCoverage(iterManager spannerclient.RowIterator) ([]*fileCoverageWithDetails, error) {
+ res := []*fileCoverageWithDetails{}
+ ch := make(chan *fileCoverageWithDetails)
+ var err error
+ go func() {
+ defer close(ch)
+ err = readIterToChan(context.Background(), iterManager, ch)
+ }()
+ for fc := range ch {
+ res = append(res, fc)
+ }
if err != nil {
- return nil, fmt.Errorf("spanner.NewClient() failed: %s", err.Error())
+ return nil, fmt.Errorf("readIterToChan: %w", err)
}
- defer client.Close()
+ return res, nil
+}
+// Unique coverage from specific manager is more expensive to get.
+// We get unique coverage comparing manager and total coverage on the AppEngine side.
+func readCoverageUniq(full, mgr spannerclient.RowIterator,
+) ([]*fileCoverageWithDetails, error) {
+ eg, ctx := errgroup.WithContext(context.Background())
+ fullCh := make(chan *fileCoverageWithLineInfo)
+ eg.Go(func() error {
+ defer close(fullCh)
+ return readIterToChan(ctx, full, fullCh)
+ })
+ partCh := make(chan *fileCoverageWithLineInfo)
+ eg.Go(func() error {
+ defer close(partCh)
+ return readIterToChan(ctx, mgr, partCh)
+ })
res := []*fileCoverageWithDetails{}
- for _, timePeriod := range scope.Periods {
- stmt := filesCoverageWithDetailsStmt(scope.Ns, scope.Subsystem, scope.Manager, timePeriod)
- iter := client.Single().Query(ctx, stmt)
- defer iter.Stop()
- for {
- row, err := iter.Next()
- if err == iterator.Done {
- break
+ eg.Go(func() error {
+ partCov := <-partCh
+ for fullCov := range fullCh {
+ if partCov == nil || partCov.Filepath > fullCov.Filepath {
+ // No pair for the file in full aggregation is available.
+ cov := fullCov.fileCoverageWithDetails
+ cov.Covered = 0
+ res = append(res, &cov)
+ continue
+ }
+ if partCov.Filepath == fullCov.Filepath {
+ if partCov.Commit != fullCov.Commit ||
+ !IsComparable(
+ fullCov.LinesInstrumented, fullCov.HitCounts,
+ partCov.LinesInstrumented, partCov.HitCounts) {
+ return fmt.Errorf("db record for file %s doesn't match", fullCov.Filepath)
+ }
+ resItem := fullCov.fileCoverageWithDetails // Use Instrumented count from full aggregation.
+ resItem.Covered = 0
+ for _, hc := range UniqCoverage(fullCov.CovMap(), partCov.CovMap()) {
+ if hc > 0 {
+ resItem.Covered++
+ }
+ }
+ res = append(res, &resItem)
+ partCov = <-partCh
+ continue
}
+ // Partial coverage is a subset of full coverage.
+ // File can't exist only in partial set.
+ return fmt.Errorf("currupted db, file %s can't exist", partCov.Filepath)
+ }
+ return nil
+ })
+ if err := eg.Wait(); err != nil {
+ return nil, fmt.Errorf("eg.Wait: %w", err)
+ }
+ return res, nil
+}
+
+func MakeCovMap(keys, vals []int64) map[int]int64 {
+ res := map[int]int64{}
+ for i, key := range keys {
+ res[int(key)] = vals[i]
+ }
+ return res
+}
+
+func IsComparable(fullLines, fullHitCounts, partialLines, partialHitCounts []int64) bool {
+ if len(fullLines) != len(fullHitCounts) ||
+ len(partialLines) != len(partialHitCounts) ||
+ len(fullLines) < len(partialLines) {
+ return false
+ }
+ fullCov := MakeCovMap(fullLines, fullHitCounts)
+ for iPartial, ln := range partialLines {
+ partialHitCount := partialHitCounts[iPartial]
+ if fullHitCount, fullExist := fullCov[int(ln)]; !fullExist || fullHitCount < partialHitCount {
+ return false
+ }
+ }
+ return true
+}
+
+// Returns partial hitcounts that are the only source of the full hitcounts.
+func UniqCoverage(fullCov, partCov map[int]int64) map[int]int64 {
+ res := maps.Clone(partCov)
+ for ln := range partCov {
+ if partCov[ln] != fullCov[ln] {
+ res[ln] = 0
+ }
+ }
+ return res
+}
+
+func readIterToChan[K fileCoverageWithLineInfo | fileCoverageWithDetails](
+ ctx context.Context, iter spannerclient.RowIterator, ch chan<- *K) error {
+ for {
+ row, err := iter.Next()
+ if err == iterator.Done {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("iter.Next: %w", err)
+ }
+ var r K
+ if err = row.ToStruct(&r); err != nil {
+ return fmt.Errorf("row.ToStruct: %w", err)
+ }
+ select {
+ case ch <- &r:
+ case <-ctx.Done():
+ return nil
+ }
+ }
+ return nil
+}
+
+func filesCoverageWithDetails(
+ ctx context.Context, client spannerclient.SpannerClient, scope *SelectScope, onlyUnique bool,
+) ([]*fileCoverageWithDetails, error) {
+ var res []*fileCoverageWithDetails
+ for _, timePeriod := range scope.Periods {
+ needLinesDetails := onlyUnique
+ iterManager := client.Single().Query(ctx,
+ filesCoverageWithDetailsStmt(scope.Ns, scope.Subsystem, scope.Manager, timePeriod, needLinesDetails))
+ defer iterManager.Stop()
+
+ var err error
+ var periodRes []*fileCoverageWithDetails
+ if onlyUnique {
+ iterAll := client.Single().Query(ctx,
+ filesCoverageWithDetailsStmt(scope.Ns, scope.Subsystem, "", timePeriod, needLinesDetails))
+ defer iterAll.Stop()
+ periodRes, err = readCoverageUniq(iterAll, iterManager)
if err != nil {
- return nil, fmt.Errorf("failed to iter.Next() spanner DB: %w", err)
+ return nil, fmt.Errorf("uniqueFilesCoverageWithDetails: %w", err)
}
- var r fileCoverageWithDetails
- if err = row.ToStruct(&r); err != nil {
- return nil, fmt.Errorf("failed to row.ToStruct() spanner DB: %w", err)
+ } else {
+ periodRes, err = readCoverage(iterManager)
+ if err != nil {
+ return nil, fmt.Errorf("readCoverage: %w", err)
}
+ }
+ for _, r := range periodRes {
r.TimePeriod = timePeriod
- res = append(res, &r)
}
+ res = append(res, periodRes...)
}
return res, nil
}
@@ -252,9 +396,10 @@ type SelectScope struct {
Periods []coveragedb.TimePeriod
}
-func DoHeatMapStyleBodyJS(ctx context.Context, projectID string, scope *SelectScope, sss, managers []string,
+func DoHeatMapStyleBodyJS(
+ ctx context.Context, client spannerclient.SpannerClient, scope *SelectScope, onlyUnique bool, sss, managers []string,
) (template.CSS, template.HTML, template.HTML, error) {
- covAndDates, err := filesCoverageWithDetails(ctx, projectID, scope)
+ covAndDates, err := filesCoverageWithDetails(ctx, client, scope, onlyUnique)
if err != nil {
return "", "", "", fmt.Errorf("failed to filesCoverageWithDetails: %w", err)
}
@@ -264,9 +409,10 @@ func DoHeatMapStyleBodyJS(ctx context.Context, projectID string, scope *SelectSc
return stylesBodyJSTemplate(templData)
}
-func DoSubsystemsHeatMapStyleBodyJS(ctx context.Context, projectID string, scope *SelectScope, sss, managers []string,
+func DoSubsystemsHeatMapStyleBodyJS(
+ ctx context.Context, client spannerclient.SpannerClient, scope *SelectScope, onlyUnique bool, sss, managers []string,
) (template.CSS, template.HTML, template.HTML, error) {
- covWithDetails, err := filesCoverageWithDetails(ctx, projectID, scope)
+ covWithDetails, err := filesCoverageWithDetails(ctx, client, scope, onlyUnique)
if err != nil {
panic(err)
}
diff --git a/pkg/cover/heatmap_test.go b/pkg/cover/heatmap_test.go
index d872e31d1..ad1f9bf64 100644
--- a/pkg/cover/heatmap_test.go
+++ b/pkg/cover/heatmap_test.go
@@ -4,14 +4,254 @@
package cover
import (
+ "context"
"testing"
"time"
"cloud.google.com/go/civil"
"github.com/google/syzkaller/pkg/coveragedb"
+ "github.com/google/syzkaller/pkg/coveragedb/mocks"
+ "github.com/google/syzkaller/pkg/coveragedb/spannerclient"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "google.golang.org/api/iterator"
)
+func TestFilesCoverageWithDetails(t *testing.T) {
+ period, _ := coveragedb.MakeTimePeriod(
+ civil.Date{Year: 2025, Month: 1, Day: 1},
+ "day")
+ tests := []struct {
+ name string
+ scope *SelectScope
+ client func() spannerclient.SpannerClient
+ onlyUnique bool
+ want []*fileCoverageWithDetails
+ wantErr bool
+ }{
+ {
+ name: "empty scope",
+ scope: &SelectScope{},
+ want: nil,
+ wantErr: false,
+ },
+ {
+ name: "single day, no filters, empty DB => no coverage",
+ scope: &SelectScope{
+ Ns: "upstream",
+ Periods: []coveragedb.TimePeriod{period},
+ },
+ client: func() spannerclient.SpannerClient { return emptyCoverageDBFixture(t, 1) },
+ want: nil,
+ wantErr: false,
+ },
+ {
+ name: "single day, unique coverage, empty DB => no coverage",
+ scope: &SelectScope{
+ Ns: "upstream",
+ Periods: []coveragedb.TimePeriod{period},
+ },
+ client: func() spannerclient.SpannerClient { return emptyCoverageDBFixture(t, 2) },
+ onlyUnique: true,
+ want: nil,
+ wantErr: false,
+ },
+ {
+ name: "single day, unique coverage, empty partial result => 0/3 covered",
+ scope: &SelectScope{
+ Ns: "upstream",
+ Periods: []coveragedb.TimePeriod{period},
+ },
+ client: func() spannerclient.SpannerClient {
+ return fullCoverageDBFixture(
+ t,
+ []*fileCoverageWithLineInfo{
+ {
+ fileCoverageWithDetails: fileCoverageWithDetails{
+ Filepath: "file1",
+ Instrumented: 3,
+ Covered: 3,
+ },
+ LinesInstrumented: []int64{1, 2, 3},
+ HitCounts: []int64{1, 1, 1},
+ },
+ },
+ nil)
+ },
+ onlyUnique: true,
+ want: []*fileCoverageWithDetails{
+ {
+ Filepath: "file1",
+ Instrumented: 3,
+ Covered: 0,
+ TimePeriod: period,
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "single day, unique coverage, full result match => 3/3 covered",
+ scope: &SelectScope{
+ Ns: "upstream",
+ Periods: []coveragedb.TimePeriod{period},
+ },
+ client: func() spannerclient.SpannerClient {
+ return fullCoverageDBFixture(
+ t,
+ []*fileCoverageWithLineInfo{
+ {
+ fileCoverageWithDetails: fileCoverageWithDetails{
+ Filepath: "file1",
+ Instrumented: 3,
+ Covered: 3,
+ },
+ LinesInstrumented: []int64{1, 2, 3},
+ HitCounts: []int64{1, 1, 1},
+ },
+ },
+ []*fileCoverageWithLineInfo{
+ {
+ fileCoverageWithDetails: fileCoverageWithDetails{
+ Filepath: "file1",
+ Instrumented: 3,
+ Covered: 3,
+ },
+ LinesInstrumented: []int64{1, 2, 3},
+ HitCounts: []int64{1, 1, 1},
+ },
+ })
+ },
+ onlyUnique: true,
+ want: []*fileCoverageWithDetails{
+ {
+ Filepath: "file1",
+ Instrumented: 3,
+ Covered: 3,
+ TimePeriod: period,
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "single day, unique coverage, partial result match => 3/5 covered",
+ scope: &SelectScope{
+ Ns: "upstream",
+ Periods: []coveragedb.TimePeriod{period},
+ },
+ client: func() spannerclient.SpannerClient {
+ return fullCoverageDBFixture(
+ t,
+ []*fileCoverageWithLineInfo{
+ {
+ fileCoverageWithDetails: fileCoverageWithDetails{
+ Filepath: "file1",
+ Instrumented: 5,
+ Covered: 5,
+ },
+ LinesInstrumented: []int64{1, 2, 3, 4, 5},
+ HitCounts: []int64{3, 4, 5, 6, 7},
+ },
+ },
+ []*fileCoverageWithLineInfo{
+ {
+ fileCoverageWithDetails: fileCoverageWithDetails{
+ Filepath: "file1",
+ Instrumented: 4,
+ Covered: 3,
+ },
+ LinesInstrumented: []int64{1, 2, 3, 5},
+ HitCounts: []int64{3, 0, 5, 7},
+ },
+ })
+ },
+ onlyUnique: true,
+ want: []*fileCoverageWithDetails{
+ {
+ Filepath: "file1",
+ Instrumented: 5,
+ Covered: 3,
+ TimePeriod: period,
+ },
+ },
+ wantErr: false,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ var testClient spannerclient.SpannerClient
+ if test.client != nil {
+ testClient = test.client()
+ }
+ got, gotErr := filesCoverageWithDetails(
+ context.Background(),
+ testClient, test.scope, test.onlyUnique)
+ if test.wantErr {
+ assert.Error(t, gotErr)
+ } else {
+ assert.NoError(t, gotErr)
+ }
+ assert.Equal(t, test.want, got)
+ })
+ }
+}
+
+func emptyCoverageDBFixture(t *testing.T, times int) spannerclient.SpannerClient {
+ mRowIterator := mocks.NewRowIterator(t)
+ mRowIterator.On("Stop").Return().Times(times)
+ mRowIterator.On("Next").
+ Return(nil, iterator.Done).Times(times)
+
+ mTran := mocks.NewReadOnlyTransaction(t)
+ mTran.On("Query", mock.Anything, mock.Anything).
+ Return(mRowIterator).Times(times)
+
+ m := mocks.NewSpannerClient(t)
+ m.On("Single").
+ Return(mTran).Times(times)
+ return m
+}
+
+func fullCoverageDBFixture(
+ t *testing.T, full, partial []*fileCoverageWithLineInfo,
+) spannerclient.SpannerClient {
+ mPartialTran := mocks.NewReadOnlyTransaction(t)
+ mPartialTran.On("Query", mock.Anything, mock.Anything).
+ Return(newRowIteratorMock(t, partial)).Once()
+
+ mFullTran := mocks.NewReadOnlyTransaction(t)
+ mFullTran.On("Query", mock.Anything, mock.Anything).
+ Return(newRowIteratorMock(t, full)).Once()
+
+ m := mocks.NewSpannerClient(t)
+ m.On("Single").
+ Return(mPartialTran).Once()
+ m.On("Single").
+ Return(mFullTran).Once()
+ return m
+}
+
+func newRowIteratorMock(t *testing.T, events []*fileCoverageWithLineInfo,
+) *mocks.RowIterator {
+ m := mocks.NewRowIterator(t)
+ m.On("Stop").Once().Return()
+ for _, item := range events {
+ mRow := mocks.NewRow(t)
+ mRow.On("ToStruct", mock.Anything).
+ Run(func(args mock.Arguments) {
+ arg := args.Get(0).(*fileCoverageWithLineInfo)
+ *arg = *item
+ }).
+ Return(nil).Once()
+
+ m.On("Next").
+ Return(mRow, nil).Once()
+ }
+
+ m.On("Next").
+ Return(nil, iterator.Done).Once()
+ return m
+}
+
func TestFilesCoverageToTemplateData(t *testing.T) {
tests := []struct {
name string
diff --git a/pkg/cover/templates/heatmap.html b/pkg/cover/templates/heatmap.html
index c3fcd5db7..cc5da9bcc 100644
--- a/pkg/cover/templates/heatmap.html
+++ b/pkg/cover/templates/heatmap.html
@@ -115,7 +115,7 @@
<br>
<label for="target-manager">Manager:</label>
<br>
- <label for="only-unique">Only unique:</label>
+ <label for="unique-only">Only unique:</label>
</div>
<div style="display:inline-block; vertical-align: top">
<select id="target-period" name="period">
@@ -139,7 +139,7 @@
{{ end }}
</select>
<br>
- <input type="checkbox" id="only-unique" name="unique-only" disabled>
+ <input type="checkbox" id="unique-only" name="unique-only" value="1">
</div>
<br>
<button id="updateButton">Update</button>
diff --git a/pkg/coveragedb/coveragedb.go b/pkg/coveragedb/coveragedb.go
index 4488d1b6b..22e84d8eb 100644
--- a/pkg/coveragedb/coveragedb.go
+++ b/pkg/coveragedb/coveragedb.go
@@ -57,10 +57,10 @@ type Coverage struct {
HitCounts []int64
}
-func (c *Coverage) AddLineHitCount(line, hitCount int) {
+func (c *Coverage) AddLineHitCount(line int, hitCount int64) {
c.Instrumented++
c.LinesInstrumented = append(c.LinesInstrumented, int64(line))
- c.HitCounts = append(c.HitCounts, int64(hitCount))
+ c.HitCounts = append(c.HitCounts, hitCount)
if hitCount > 0 {
c.Covered++
}
@@ -122,7 +122,10 @@ type linesCoverage struct {
HitCounts []int64
}
-func linesCoverageStmt(ns, filepath, commit string, timePeriod TimePeriod) spanner.Statement {
+func linesCoverageStmt(ns, filepath, commit, manager string, timePeriod TimePeriod) spanner.Statement {
+ if manager == "" {
+ manager = "*"
+ }
return spanner.Statement{
SQL: `
select
@@ -132,47 +135,43 @@ from merge_history
join files
on merge_history.session = files.session
where
- namespace=$1 and dateto=$2 and duration=$3 and filepath=$4 and commit=$5 and manager='*'`,
+ namespace=$1 and dateto=$2 and duration=$3 and filepath=$4 and commit=$5 and manager=$6`,
Params: map[string]interface{}{
"p1": ns,
"p2": timePeriod.DateTo,
"p3": timePeriod.Days,
"p4": filepath,
"p5": commit,
+ "p6": manager,
},
}
}
-func ReadLinesHitCount(ctx context.Context, ns, commit, file string, tp TimePeriod,
-) (map[int]int, error) {
+func ReadLinesHitCount(ctx context.Context, ns, commit, file, manager string, tp TimePeriod,
+) ([]int64, []int64, error) {
projectID := os.Getenv("GOOGLE_CLOUD_PROJECT")
client, err := spannerclient.NewClient(ctx, projectID)
if err != nil {
- return nil, fmt.Errorf("spanner.NewClient: %w", err)
+ return nil, nil, fmt.Errorf("spanner.NewClient: %w", err)
}
defer client.Close()
- stmt := linesCoverageStmt(ns, file, commit, tp)
+ stmt := linesCoverageStmt(ns, file, commit, manager, tp)
iter := client.Single().Query(ctx, stmt)
defer iter.Stop()
row, err := iter.Next()
if err == iterator.Done {
- return nil, nil
+ return nil, nil, nil
}
if err != nil {
- return nil, fmt.Errorf("iter.Next: %w", err)
+ return nil, nil, fmt.Errorf("iter.Next: %w", err)
}
var r linesCoverage
if err = row.ToStruct(&r); err != nil {
- return nil, fmt.Errorf("failed to row.ToStruct() spanner DB: %w", err)
- }
-
- res := map[int]int{}
- for i, instrLine := range r.LinesInstrumented {
- res[int(instrLine)] = int(r.HitCounts[i])
+ return nil, nil, fmt.Errorf("failed to row.ToStruct() spanner DB: %w", err)
}
- return res, nil
+ return r.LinesInstrumented, r.HitCounts, nil
}
func historyMutation(session string, template *HistoryRecord) *spanner.Mutation {
diff --git a/pkg/coveragedb/coveragedb_mock_test.go b/pkg/coveragedb/coveragedb_mock_test.go
index 8f20a43ed..b0ffb7815 100644
--- a/pkg/coveragedb/coveragedb_mock_test.go
+++ b/pkg/coveragedb/coveragedb_mock_test.go
@@ -19,6 +19,9 @@ import (
)
//go:generate ../../tools/mockery.sh --name SpannerClient -r
+//go:generate ../../tools/mockery.sh --name ReadOnlyTransaction -r
+//go:generate ../../tools/mockery.sh --name RowIterator -r
+//go:generate ../../tools/mockery.sh --name Row -r
type spannerMockTune func(*testing.T, *mocks.SpannerClient)
diff --git a/pkg/coveragedb/mocks/ReadOnlyTransaction.go b/pkg/coveragedb/mocks/ReadOnlyTransaction.go
new file mode 100644
index 000000000..79a75cb72
--- /dev/null
+++ b/pkg/coveragedb/mocks/ReadOnlyTransaction.go
@@ -0,0 +1,51 @@
+// Code generated by mockery v2.45.1. DO NOT EDIT.
+
+package mocks
+
+import (
+ context "context"
+
+ spanner "cloud.google.com/go/spanner"
+ mock "github.com/stretchr/testify/mock"
+
+ spannerclient "github.com/google/syzkaller/pkg/coveragedb/spannerclient"
+)
+
+// ReadOnlyTransaction is an autogenerated mock type for the ReadOnlyTransaction type
+type ReadOnlyTransaction struct {
+ mock.Mock
+}
+
+// Query provides a mock function with given fields: ctx, statement
+func (_m *ReadOnlyTransaction) Query(ctx context.Context, statement spanner.Statement) spannerclient.RowIterator {
+ ret := _m.Called(ctx, statement)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Query")
+ }
+
+ var r0 spannerclient.RowIterator
+ if rf, ok := ret.Get(0).(func(context.Context, spanner.Statement) spannerclient.RowIterator); ok {
+ r0 = rf(ctx, statement)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(spannerclient.RowIterator)
+ }
+ }
+
+ return r0
+}
+
+// NewReadOnlyTransaction creates a new instance of ReadOnlyTransaction. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewReadOnlyTransaction(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *ReadOnlyTransaction {
+ mock := &ReadOnlyTransaction{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/pkg/coveragedb/mocks/Row.go b/pkg/coveragedb/mocks/Row.go
new file mode 100644
index 000000000..a8ac4ce78
--- /dev/null
+++ b/pkg/coveragedb/mocks/Row.go
@@ -0,0 +1,42 @@
+// Code generated by mockery v2.45.1. DO NOT EDIT.
+
+package mocks
+
+import mock "github.com/stretchr/testify/mock"
+
+// Row is an autogenerated mock type for the Row type
+type Row struct {
+ mock.Mock
+}
+
+// ToStruct provides a mock function with given fields: p
+func (_m *Row) ToStruct(p interface{}) error {
+ ret := _m.Called(p)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ToStruct")
+ }
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(interface{}) error); ok {
+ r0 = rf(p)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
+// NewRow creates a new instance of Row. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewRow(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *Row {
+ mock := &Row{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/pkg/coveragedb/mocks/RowIterator.go b/pkg/coveragedb/mocks/RowIterator.go
new file mode 100644
index 000000000..865240d3b
--- /dev/null
+++ b/pkg/coveragedb/mocks/RowIterator.go
@@ -0,0 +1,62 @@
+// Code generated by mockery v2.45.1. DO NOT EDIT.
+
+package mocks
+
+import (
+ spannerclient "github.com/google/syzkaller/pkg/coveragedb/spannerclient"
+ mock "github.com/stretchr/testify/mock"
+)
+
+// RowIterator is an autogenerated mock type for the RowIterator type
+type RowIterator struct {
+ mock.Mock
+}
+
+// Next provides a mock function with given fields:
+func (_m *RowIterator) Next() (spannerclient.Row, error) {
+ ret := _m.Called()
+
+ if len(ret) == 0 {
+ panic("no return value specified for Next")
+ }
+
+ var r0 spannerclient.Row
+ var r1 error
+ if rf, ok := ret.Get(0).(func() (spannerclient.Row, error)); ok {
+ return rf()
+ }
+ if rf, ok := ret.Get(0).(func() spannerclient.Row); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(spannerclient.Row)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// Stop provides a mock function with given fields:
+func (_m *RowIterator) Stop() {
+ _m.Called()
+}
+
+// NewRowIterator creates a new instance of RowIterator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewRowIterator(t interface {
+ mock.TestingT
+ Cleanup(func())
+}) *RowIterator {
+ mock := &RowIterator{}
+ mock.Mock.Test(t)
+
+ t.Cleanup(func() { mock.AssertExpectations(t) })
+
+ return mock
+}
diff --git a/pkg/covermerger/covermerger.go b/pkg/covermerger/covermerger.go
index 11ed5c043..34b534593 100644
--- a/pkg/covermerger/covermerger.go
+++ b/pkg/covermerger/covermerger.go
@@ -44,7 +44,7 @@ type RepoCommit struct {
}
type MergeResult struct {
- HitCounts map[int]int
+ HitCounts map[int]int64
FileExists bool
LineDetails map[int][]*FileRecord
}
@@ -118,10 +118,10 @@ func mergedCoverageRecords(fmr *FileMergeResult) []*coveragedb.MergedCoverageRec
for _, line := range lines {
mgrStat[allManagers].AddLineHitCount(line, fmr.HitCounts[line])
- managerHitCounts := map[string]int{}
+ managerHitCounts := map[string]int64{}
for _, lineDetail := range fmr.LineDetails[line] {
manager := lineDetail.Manager
- managerHitCounts[manager] += lineDetail.HitCount
+ managerHitCounts[manager] += int64(lineDetail.HitCount)
}
for manager, managerHitCount := range managerHitCounts {
if _, ok := mgrStat[manager]; !ok {
diff --git a/pkg/covermerger/file_line_merger.go b/pkg/covermerger/file_line_merger.go
index 5d9c0ab7a..ebc747f47 100644
--- a/pkg/covermerger/file_line_merger.go
+++ b/pkg/covermerger/file_line_merger.go
@@ -20,7 +20,7 @@ func makeFileLineCoverMerger(fvs fileVersions, base RepoCommit) FileCoverageMerg
}
a := &FileLineCoverMerger{
MergeResult: &MergeResult{
- HitCounts: make(map[int]int),
+ HitCounts: make(map[int]int64),
FileExists: true,
LineDetails: make(map[int][]*FileRecord),
},
@@ -49,7 +49,7 @@ func (a *FileLineCoverMerger) Add(record *FileRecord) {
return
}
if targetLine := a.matchers[record.RepoCommit].SameLinePos(record.StartLine); targetLine != -1 {
- a.HitCounts[targetLine] += record.HitCount
+ a.HitCounts[targetLine] += int64(record.HitCount)
a.LineDetails[targetLine] = append(a.LineDetails[targetLine], record)
}
}
diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go
index 9aebc8150..b9031302a 100644
--- a/pkg/validator/validator.go
+++ b/pkg/validator/validator.go
@@ -48,12 +48,33 @@ func PanicIfNot(results ...Result) error {
return nil
}
+var ErrValueNotAllowed = errors.New("value is not allowed")
+
+func Allowlisted(str string, allowlist []string, valueName ...string) Result {
+ for _, allowed := range allowlist {
+ if allowed == str {
+ return Result{
+ Ok: true,
+ }
+ }
+ }
+ if len(valueName) == 0 {
+ return Result{
+ Err: fmt.Errorf("value %s is not allowed", str),
+ }
+ }
+ return Result{
+ Err: fmt.Errorf("%s(%s) is not allowed", valueName[0], str),
+ }
+}
+
var (
EmptyStr = makeStrLenFunc("not empty", 0)
AlphaNumeric = makeStrReFunc("not an alphanum", "^[a-zA-Z0-9]*$")
CommitHash = makeCombinedStrFunc("not a hash", AlphaNumeric, makeStrLenFunc("len is not 40", 40))
KernelFilePath = makeStrReFunc("not a kernel file path", "^[./_a-zA-Z0-9-]*$")
NamespaceName = makeStrReFunc("not a namespace name", "^[a-zA-Z0-9_.-]{4,32}$")
+ ManagerName = makeStrReFunc("not a manager name", "^ci[a-z0-9-]*$")
DashClientName = makeStrReFunc("not a dashboard client name", "^[a-zA-Z0-9_.-]{4,100}$")
DashClientKey = makeStrReFunc("not a dashboard client key",
"^([a-zA-Z0-9]{16,128})|("+regexp.QuoteMeta(auth.OauthMagic)+".*)$")
diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go
index 9aa08e2d0..ffe935600 100644
--- a/pkg/validator/validator_test.go
+++ b/pkg/validator/validator_test.go
@@ -36,6 +36,16 @@ func TestIsNamespaceName(t *testing.T) {
}
// nolint: dupl
+func TestIsManagerName(t *testing.T) {
+ assert.True(t, validator.ManagerName("ci-upstream").Ok)
+ assert.False(t, validator.ManagerName("").Ok)
+
+ assert.Equal(t, "not a manager name", validator.ManagerName("*").Err.Error())
+ assert.Equal(t, "manager: not a manager name",
+ validator.ManagerName("*", "manager").Err.Error())
+}
+
+// nolint: dupl
func TestIsDashboardClientName(t *testing.T) {
assert.True(t, validator.DashClientName("name").Ok)
assert.False(t, validator.DashClientName("").Ok)
@@ -86,3 +96,11 @@ func TestAnyOk(t *testing.T) {
assert.Equal(t, badResult, validator.AnyOk(badResult))
assert.Equal(t, validator.ResultOk, validator.AnyOk(badResult, validator.ResultOk))
}
+
+func TestAllowlisted(t *testing.T) {
+ assert.True(t, validator.Allowlisted("good", []string{"good", "also-good"}).Ok)
+ assert.False(t, validator.Allowlisted("bad", []string{"good", "also-good"}).Ok)
+ assert.Equal(t,
+ validator.Result{Ok: false, Err: errors.New("name(bad) is not allowed")},
+ validator.Allowlisted("bad", nil, "name"))
+}