diff --git a/db/pg.go b/db/pg.go index 27e1a12..e18d43c 100644 --- a/db/pg.go +++ b/db/pg.go @@ -564,6 +564,25 @@ func (p *PG) ListRunsForPackage(ctx context.Context, pkg string, limit int) ([]* return runs, nil } +// runSummaryLookback bounds how far before `begin` ListRunSummariesInRange +// looks for runs that started earlier but were still executing at `begin`. +// It keeps the query a bounded range scan on runs_started_at_finished_at_idx +// instead of an open-ended `started_at < end`. It only needs to exceed the +// longest possible run: the scheduler resets or fails any run that exceeds +// its run timeout (15m by default), so a day is a very generous margin. +const runSummaryLookback = 24 * time.Hour + +// ListRunSummariesInRange buckets the runs that were executing at any point +// during [begin, end) into windows of `window` and summarises them per +// package. See tester.RunSummary for the bucket semantics. +// +// The query is a LEFT JOIN from runs to tests so a run without tests (an +// errored or killed run whose test binary never reported anything) still +// shows up in ErrorRunIDs. Only the columns the summary needs are selected +// and the test name and state are extracted from the result jsonb in SQL, +// rather than transferring and unmarshalling the whole document; for the +// dashboard's 30-day call that is the difference between ~40 MB and a few +// MB over the wire. func (p *PG) ListRunSummariesInRange(ctx context.Context, begin, end time.Time, window time.Duration) ([]*tester.RunSummary, error) { begin = begin.UTC() end = end.UTC() @@ -577,15 +596,51 @@ func (p *PG) ListRunSummariesInRange(ctx context.Context, begin, end time.Time, PackageSummary: make(map[string]*tester.PackageSummary), } } + if buckets == 0 { + return summaries, nil + } + + // timestamptz has microsecond precision (and pgx truncates parameters to + // match), so do the bucket arithmetic against a `begin` truncated the same + // way: otherwise a run stored as started exactly at `begin` computes a + // negative offset from a `begin` that still carries nanoseconds. + begin = begin.Truncate(time.Microsecond) + end = end.Truncate(time.Microsecond) + now := p.now().UTC().Truncate(time.Microsecond) + + packageSummary := func(bucket int, pkg string) *tester.PackageSummary { + summary := summaries[bucket] + ps, ok := summary.PackageSummary[pkg] + if !ok { + ps = &tester.PackageSummary{ + Package: pkg, + PassedTests: make(map[string][]uuid.UUID), + FailedTests: make(map[string][]uuid.UUID), + SkippedTests: make(map[string][]uuid.UUID), + } + summary.PackageSummary[pkg] = ps + } + return ps + } err := p.tx(ctx, func(tx pgx.Tx) error { - q := psq.Select("runs.package", "runs.id", "runs.started_at", "runs.error", "tests.id", "tests.result"). - From("tests"). - Join("runs ON tests.run_id = runs.id"). + q := psq.Select( + "runs.package", + "runs.id", + "runs.started_at", + "runs.finished_at", + "(runs.error IS NOT NULL) AS errored", + "tests.id", + "tests.result->>'name'", + "tests.result->>'state'", + ). + From("runs"). + // The tests of an errored run are not counted, so do not fetch them. + LeftJoin("tests ON tests.run_id = runs.id AND runs.error IS NULL"). Where("runs.started_at IS NOT NULL"). - Where("runs.started_at >= ?", begin). - Where("runs.started_at <= ?", end). - Where("runs.finished_at IS NOT NULL"). + Where("runs.started_at >= ?", begin.Add(-runSummaryLookback)). + Where("runs.started_at < ?", end). + Where("(runs.finished_at IS NULL OR runs.finished_at >= ?)", begin). OrderBy("runs.started_at ASC") query, args, err := q.ToSql() @@ -599,49 +654,78 @@ func (p *PG) ListRunSummariesInRange(ctx context.Context, begin, end time.Time, } defer rows.Close() + // Rows are one per (run, test) pair, or a single row with NULL test + // columns for a run without tests. Run ids are recorded once per run + // on its first row; tests are recorded on every row that has one. + seenRuns := make(map[uuid.UUID]struct{}) + for rows.Next() { var ( - packageName string - runID uuid.UUID - runStartedAt time.Time - runError sql.NullString - testID uuid.UUID - result tester.T + pkg string + runID uuid.UUID + startedAt time.Time + finishedAt sql.NullTime + errored bool + testID uuid.NullUUID + testName sql.NullString + testState sql.NullString + startBucket int ) - err := rows.Scan(&packageName, &runID, &runStartedAt, &runError, &testID, &result) + err := rows.Scan(&pkg, &runID, &startedAt, &finishedAt, &errored, &testID, &testName, &testState) if err != nil { return err } - runStartedAt = runStartedAt.UTC() - - bucketIndex := int(runStartedAt.Sub(begin) / window) - summary := summaries[bucketIndex] - - packageSummary, ok := summary.PackageSummary[packageName] - if !ok { - packageSummary = &tester.PackageSummary{ - Package: packageName, - PassedTests: make(map[string][]uuid.UUID), - FailedTests: make(map[string][]uuid.UUID), - SkippedTests: make(map[string][]uuid.UUID), + + // The bucket the run started in may be before `begin` (the run + // was already executing when the range starts), in which case it + // is negative and its tests were counted by a previous range. + startBucket = bucketIndex(startedAt.UTC().Sub(begin), window) + + if _, seen := seenRuns[runID]; !seen { + seenRuns[runID] = struct{}{} + + // A run is attributed to every bucket it overlapped with, as + // the half-open interval [started_at, finished_at). A run that + // is still running extends to now. + until := now + if finishedAt.Valid { + until = finishedAt.Time.UTC() + } + lastBucket := startBucket + if until.After(startedAt) { + lastBucket = bucketIndex(until.Sub(begin)-time.Nanosecond, window) + } + if lastBucket < startBucket { + lastBucket = startBucket + } + + for b := max(startBucket, 0); b <= min(lastBucket, buckets-1); b++ { + ps := packageSummary(b, pkg) + switch { + case errored: + ps.ErrorRunIDs = append(ps.ErrorRunIDs, runID) + case !finishedAt.Valid: + ps.RunIDs = append(ps.RunIDs, runID) + ps.RunningRunIDs = append(ps.RunningRunIDs, runID) + default: + ps.RunIDs = append(ps.RunIDs, runID) + } } - summary.PackageSummary[packageName] = packageSummary } - // NOTE(nan) we blindly add here and uniquify later. - if runError.Valid { - packageSummary.ErrorRunIDs = append(packageSummary.ErrorRunIDs, runID) + // Tests are only counted in the bucket their run started in, so + // a run spanning several buckets is not counted several times. + if !testID.Valid || startBucket < 0 || startBucket >= buckets { continue } - packageSummary.RunIDs = append(packageSummary.RunIDs, runID) - - switch result.State { + ps := packageSummary(startBucket, pkg) + switch tester.TBState(testState.String) { case tester.TBStatePassed: - packageSummary.PassedTests[result.Name] = append(packageSummary.PassedTests[result.Name], testID) + ps.PassedTests[testName.String] = append(ps.PassedTests[testName.String], testID.UUID) case tester.TBStateFailed: - packageSummary.FailedTests[result.Name] = append(packageSummary.FailedTests[result.Name], testID) + ps.FailedTests[testName.String] = append(ps.FailedTests[testName.String], testID.UUID) case tester.TBStateSkipped: - packageSummary.SkippedTests[result.Name] = append(packageSummary.SkippedTests[result.Name], testID) + ps.SkippedTests[testName.String] = append(ps.SkippedTests[testName.String], testID.UUID) } } return rows.Err() @@ -650,34 +734,16 @@ func (p *PG) ListRunSummariesInRange(ctx context.Context, begin, end time.Time, return nil, err } - for _, summary := range summaries { - for _, packageSummary := range summary.PackageSummary { - if len(packageSummary.RunIDs) == 0 { - continue - } - - var runIDs []uuid.UUID - uniqueRunIDs := make(map[uuid.UUID]struct{}) - for _, id := range packageSummary.RunIDs { - if _, exists := uniqueRunIDs[id]; exists { - continue - } - uniqueRunIDs[id] = struct{}{} - runIDs = append(runIDs, id) - } - packageSummary.RunIDs = runIDs + return summaries, nil +} - var errorRunIDs []uuid.UUID - uniqueErrorRunIDs := make(map[uuid.UUID]struct{}) - for _, id := range packageSummary.ErrorRunIDs { - if _, exists := uniqueErrorRunIDs[id]; exists { - continue - } - uniqueErrorRunIDs[id] = struct{}{} - errorRunIDs = append(errorRunIDs, id) - } - packageSummary.ErrorRunIDs = errorRunIDs - } +// bucketIndex returns the index of the window that the offset from the start +// of a range falls in, rounding towards negative infinity so offsets before +// the start map to negative indexes rather than to bucket 0. +func bucketIndex(offset, window time.Duration) int { + i := int(offset / window) + if offset%window < 0 { + i-- } - return summaries, nil + return i } diff --git a/db/pg_test.go b/db/pg_test.go index 75f7939..a335996 100644 --- a/db/pg_test.go +++ b/db/pg_test.go @@ -878,6 +878,243 @@ func TestPG_ListRunSummariesInRange(t *testing.T) { }, }, }, summaries[0]) + + // pkg2run2 errored without submitting any tests; it must still + // appear in its bucket (the join to tests is a LEFT JOIN). + assert.Equal(t, &tester.PackageSummary{ + Package: "pkg-2", + RunIDs: []uuid.UUID{pkg2run1.ID}, + ErrorRunIDs: []uuid.UUID{pkg2run2.ID}, + PassedTests: map[string][]uuid.UUID{"test-pass": {pkg2run1.Tests[0].ID}}, + FailedTests: map[string][]uuid.UUID{}, + SkippedTests: map[string][]uuid.UUID{}, + }, summaries[2].PackageSummary["pkg-2"]) + }) + }) + + t.Run("errored run with no tests appears in ErrorRunIDs", func(t *testing.T) { + withPG(t, func(tb testing.TB, pg *PG) { + begin := time.Now().UTC() + end := begin.Add(2 * time.Minute) + window := time.Minute + + run := &tester.Run{ + ID: uuid.New(), + Package: "pkg", + EnqueuedAt: begin, + StartedAt: begin.Add(10 * time.Second), + FinishedAt: begin.Add(20 * time.Second), + Error: "killed", + } + require.NoError(t, pg.EnqueueRun(ctx, run)) + + summaries, err := pg.ListRunSummariesInRange(ctx, begin, end, window) + require.NoError(t, err) + require.Len(t, summaries, 2) + assert.Equal(t, &tester.PackageSummary{ + Package: "pkg", + ErrorRunIDs: []uuid.UUID{run.ID}, + PassedTests: map[string][]uuid.UUID{}, + FailedTests: map[string][]uuid.UUID{}, + SkippedTests: map[string][]uuid.UUID{}, + }, summaries[0].PackageSummary["pkg"]) + assert.Empty(t, summaries[1].PackageSummary) }) }) + + t.Run("errored run's tests are not counted", func(t *testing.T) { + withPG(t, func(tb testing.TB, pg *PG) { + begin := time.Now().UTC() + end := begin.Add(time.Minute) + window := time.Minute + + run := &tester.Run{ + ID: uuid.New(), + Package: "pkg", + EnqueuedAt: begin, + StartedAt: begin, + FinishedAt: begin.Add(10 * time.Second), + Error: "exit status 2", + } + require.NoError(t, pg.EnqueueRun(ctx, run)) + require.NoError(t, pg.AddTest(ctx, &tester.Test{ + ID: uuid.New(), RunID: run.ID, Package: run.Package, + Result: &tester.T{TB: tester.TB{Name: "test-pass", State: tester.TBStatePassed}}, + })) + + summaries, err := pg.ListRunSummariesInRange(ctx, begin, end, window) + require.NoError(t, err) + require.Len(t, summaries, 1) + ps := summaries[0].PackageSummary["pkg"] + require.NotNil(t, ps) + assert.Equal(t, []uuid.UUID{run.ID}, ps.ErrorRunIDs) + assert.Empty(t, ps.RunIDs) + assert.Equal(t, 0, ps.NumTotalTests()) + }) + }) + + t.Run("running run spans buckets and its tests are counted once", func(t *testing.T) { + withPG(t, func(tb testing.TB, pg *PG) { + begin := time.Now().UTC() + end := begin.Add(4 * time.Minute) + window := time.Minute + + // "now" is 2m30s in: the run has been executing through buckets + // 0, 1 and 2 and must not appear in bucket 3. + pg.now = func() time.Time { return begin.Add(2*time.Minute + 30*time.Second) } + + run := &tester.Run{ + ID: uuid.New(), + Package: "pkg", + EnqueuedAt: begin, + StartedAt: begin.Add(30 * time.Second), + } + require.NoError(t, pg.EnqueueRun(ctx, run)) + test := &tester.Test{ + ID: uuid.New(), RunID: run.ID, Package: run.Package, + Result: &tester.T{TB: tester.TB{Name: "test-pass", State: tester.TBStatePassed}}, + } + require.NoError(t, pg.AddTest(ctx, test)) + + summaries, err := pg.ListRunSummariesInRange(ctx, begin, end, window) + require.NoError(t, err) + require.Len(t, summaries, 4) + + assert.Equal(t, &tester.PackageSummary{ + Package: "pkg", + RunIDs: []uuid.UUID{run.ID}, + RunningRunIDs: []uuid.UUID{run.ID}, + PassedTests: map[string][]uuid.UUID{"test-pass": {test.ID}}, + FailedTests: map[string][]uuid.UUID{}, + SkippedTests: map[string][]uuid.UUID{}, + }, summaries[0].PackageSummary["pkg"], "start bucket has the run and its tests") + + for _, i := range []int{1, 2} { + assert.Equal(t, &tester.PackageSummary{ + Package: "pkg", + RunIDs: []uuid.UUID{run.ID}, + RunningRunIDs: []uuid.UUID{run.ID}, + PassedTests: map[string][]uuid.UUID{}, + FailedTests: map[string][]uuid.UUID{}, + SkippedTests: map[string][]uuid.UUID{}, + }, summaries[i].PackageSummary["pkg"], "bucket %d has the run but not its tests", i) + } + assert.Empty(t, summaries[3].PackageSummary, "bucket after now is empty") + + total := 0 + for _, s := range summaries { + total += s.NumTotalTests() + } + assert.Equal(t, 1, total, "tests are counted exactly once across buckets") + assert.Equal(t, 1, summaries[1].NumRunningRuns()) + }) + }) + + t.Run("finished run spans the buckets it executed in", func(t *testing.T) { + withPG(t, func(tb testing.TB, pg *PG) { + begin := time.Now().UTC() + end := begin.Add(4 * time.Minute) + window := time.Minute + + // Finishes exactly on the bucket 2 boundary: [started, finished) + // is half-open so it must not be attributed to bucket 2. + run := &tester.Run{ + ID: uuid.New(), + Package: "pkg", + EnqueuedAt: begin, + StartedAt: begin.Add(30 * time.Second), + FinishedAt: begin.Add(2 * time.Minute), + } + require.NoError(t, pg.EnqueueRun(ctx, run)) + test := &tester.Test{ + ID: uuid.New(), RunID: run.ID, Package: run.Package, + Result: &tester.T{TB: tester.TB{Name: "test-fail", State: tester.TBStateFailed}}, + } + require.NoError(t, pg.AddTest(ctx, test)) + + summaries, err := pg.ListRunSummariesInRange(ctx, begin, end, window) + require.NoError(t, err) + require.Len(t, summaries, 4) + + assert.Equal(t, []uuid.UUID{run.ID}, summaries[0].PackageSummary["pkg"].RunIDs) + assert.Equal(t, 1, summaries[0].NumFailedTests()) + assert.Equal(t, []uuid.UUID{run.ID}, summaries[1].PackageSummary["pkg"].RunIDs) + assert.Empty(t, summaries[1].PackageSummary["pkg"].RunningRunIDs) + assert.Equal(t, 0, summaries[1].NumTotalTests()) + assert.Empty(t, summaries[2].PackageSummary) + assert.Empty(t, summaries[3].PackageSummary) + }) + }) + + t.Run("run that started before the range is attributed but its tests are not", func(t *testing.T) { + withPG(t, func(tb testing.TB, pg *PG) { + begin := time.Now().UTC() + end := begin.Add(2 * time.Minute) + window := time.Minute + + // Started 30s before the range and finished 30s into it. + overlapping := &tester.Run{ + ID: uuid.New(), + Package: "pkg", + EnqueuedAt: begin.Add(-time.Minute), + StartedAt: begin.Add(-30 * time.Second), + FinishedAt: begin.Add(30 * time.Second), + } + require.NoError(t, pg.EnqueueRun(ctx, overlapping)) + require.NoError(t, pg.AddTest(ctx, &tester.Test{ + ID: uuid.New(), RunID: overlapping.ID, Package: overlapping.Package, + Result: &tester.T{TB: tester.TB{Name: "test-pass", State: tester.TBStatePassed}}, + })) + + // Finished before the range: must not appear at all. + earlier := &tester.Run{ + ID: uuid.New(), + Package: "pkg", + EnqueuedAt: begin.Add(-time.Minute), + StartedAt: begin.Add(-40 * time.Second), + FinishedAt: begin.Add(-10 * time.Second), + } + require.NoError(t, pg.EnqueueRun(ctx, earlier)) + + // Started exactly at end: the range is half-open, must not appear. + atEnd := &tester.Run{ + ID: uuid.New(), + Package: "pkg", + EnqueuedAt: begin, + StartedAt: end, + FinishedAt: end.Add(time.Second), + } + require.NoError(t, pg.EnqueueRun(ctx, atEnd)) + + summaries, err := pg.ListRunSummariesInRange(ctx, begin, end, window) + require.NoError(t, err) + require.Len(t, summaries, 2) + assert.Equal(t, &tester.PackageSummary{ + Package: "pkg", + RunIDs: []uuid.UUID{overlapping.ID}, + PassedTests: map[string][]uuid.UUID{}, + FailedTests: map[string][]uuid.UUID{}, + SkippedTests: map[string][]uuid.UUID{}, + }, summaries[0].PackageSummary["pkg"]) + assert.Empty(t, summaries[1].PackageSummary) + }) + }) +} + +func TestBucketIndex(t *testing.T) { + for _, tc := range []struct { + offset time.Duration + want int + }{ + {0, 0}, + {59 * time.Second, 0}, + {time.Minute, 1}, + {90 * time.Second, 1}, + {-time.Nanosecond, -1}, + {-30 * time.Second, -1}, + {-time.Minute, -1}, + {-61 * time.Second, -2}, + } { + assert.Equal(t, tc.want, bucketIndex(tc.offset, time.Minute), "offset %s", tc.offset) + } } diff --git a/http/templates.go b/http/templates.go index 8dd3650..a78eec9 100644 --- a/http/templates.go +++ b/http/templates.go @@ -92,6 +92,51 @@ func parseTemplate(layout *template.Template, content string) (*template.Templat return t, err } +// summaryCounts is implemented by both *tester.RunSummary and +// *tester.PackageSummary. +type summaryCounts interface { + NumTotalTests() int + NumRuns() int + NumRunningRuns() int + NumErrorRuns() int +} + +// summaryState classifies a summary bucket for the strips: +// +// - "results": the bucket has test results; the pass/skip/fail bar is drawn. +// - "errored": no results, but at least one errored run was executing. +// - "running": no results, but at least one run is still in progress. +// - "active": no results, but a finished run was executing through this +// bucket (it started in an earlier bucket, where its tests are counted). +// - "empty": nothing happened in the bucket. +func summaryState(s summaryCounts) string { + switch v := s.(type) { + case *tester.RunSummary: + if v == nil { + return "empty" + } + case *tester.PackageSummary: + if v == nil { + return "empty" + } + case nil: + return "empty" + } + + switch { + case s.NumTotalTests() > 0: + return "results" + case s.NumErrorRuns() > 0: + return "errored" + case s.NumRunningRuns() > 0: + return "running" + case s.NumRuns() > 0: + return "active" + default: + return "empty" + } +} + type subTest struct { ParentTest *tester.T Test *tester.T @@ -164,6 +209,7 @@ func (s *UIHandler) templateFuncs() template.FuncMap { return "unknown" } }, + "summaryState": summaryState, "runState": func(run *tester.Run) string { if run.StartedAt.IsZero() { return "pending" diff --git a/http/templates/layouts/default.html b/http/templates/layouts/default.html index 0c88761..4aa657b 100644 --- a/http/templates/layouts/default.html +++ b/http/templates/layouts/default.html @@ -22,6 +22,22 @@ .subtest { font-size: 80%; } + + /* Run summary strip cells without test results. "running" is a bucket + with a run still in progress; "active" is a bucket a finished run was + executing through (its results are shown in the bucket it started in). */ + .summary-running { + background: repeating-linear-gradient(-45deg, #adb5bd 0 4px, #dee2e6 4px 8px); + background-size: 11.3px 11.3px; + animation: summary-running-stripes 1s linear infinite; + } + .summary-active { + background: repeating-linear-gradient(-45deg, #ced4da 0 4px, #e9ecef 4px 8px); + } + @keyframes summary-running-stripes { + from { background-position: 0 0; } + to { background-position: 11.3px 0; } + } diff --git a/http/templates/run_summary.html b/http/templates/run_summary.html index 27f73d5..af0013d 100644 --- a/http/templates/run_summary.html +++ b/http/templates/run_summary.html @@ -6,7 +6,37 @@

Run Results {{.RunSummary.Time | format {{if (or (not $filterPackage) (eq $pkg $filterPackage))}}
-

{{$pkg}} {{len $summary.RunIDs}} Runs {{if $summary.ErrorRunIDs}}({{len $summary.ErrorRunIDs}} erred){{end}}

+

{{$pkg}} {{len $summary.RunIDs}} Runs {{if $summary.RunningRunIDs}}({{len $summary.RunningRunIDs}} running){{end}} {{if $summary.ErrorRunIDs}}({{len $summary.ErrorRunIDs}} erred){{end}}

+ + {{if or $summary.RunningRunIDs $summary.ErrorRunIDs}} +

Runs

+
+ {{if $summary.RunningRunIDs}} +
+ Running +
+ {{range $summary.RunningRunIDs}} + + {{.}} running + + {{end}} +
+
+ {{end}} + {{if $summary.ErrorRunIDs}} +
+ Erred +
+ {{range $summary.ErrorRunIDs}} + + {{.}} erred + + {{end}} +
+
+ {{end}} +
+ {{end}}

Tests

diff --git a/http/templates/shared/package_run_summary_bar.html b/http/templates/shared/package_run_summary_bar.html index 324a207..f2dc993 100644 --- a/http/templates/shared/package_run_summary_bar.html +++ b/http/templates/shared/package_run_summary_bar.html @@ -1,5 +1,16 @@ {{ define "package_run_summary_bar" }} +{{ $state := summaryState . }} +{{ if eq $state "results" }}
+{{ else if eq $state "errored" }} +
+{{ else if eq $state "running" }} +
+{{ else if eq $state "active" }} +
+{{ else }} +
+{{ end }} {{ end }} diff --git a/http/templates/shared/package_run_summary_tooltip.html b/http/templates/shared/package_run_summary_tooltip.html index a7ab192..a441ebc 100644 --- a/http/templates/shared/package_run_summary_tooltip.html +++ b/http/templates/shared/package_run_summary_tooltip.html @@ -4,7 +4,7 @@
Runs
-
Total: {{ len .RunIDs }} {{ if len .ErrorRunIDs }}({{ len .ErrorRunIDs }} erred){{ end }}
+
Total: {{ len .RunIDs }} {{ if len .RunningRunIDs }}({{ len .RunningRunIDs }} running){{ end }} {{ if len .ErrorRunIDs }}({{ len .ErrorRunIDs }} erred){{ end }}

diff --git a/http/templates/shared/run_summary_month.html b/http/templates/shared/run_summary_month.html index d2da1a7..8bb382c 100644 --- a/http/templates/shared/run_summary_month.html +++ b/http/templates/shared/run_summary_month.html @@ -4,7 +4,7 @@
Runs
-
Total: {{ .NumRuns }} {{ if .NumErrorRuns }}({{ .NumErrorRuns }} erred){{ end }}
+
Total: {{ .NumRuns }} {{ if .NumRunningRuns }}({{ .NumRunningRuns }} running){{ end }} {{ if .NumErrorRuns }}({{ .NumErrorRuns }} erred){{ end }}

@@ -25,9 +25,20 @@
{{ end }} {{ define "run_summary_month_bar" }} +{{ $state := summaryState . }} +{{ if eq $state "results" }}
+{{ else if eq $state "errored" }} +
+{{ else if eq $state "running" }} +
+{{ else if eq $state "active" }} +
+{{ else }} +
+{{ end }} {{ end }} {{ define "run_summary_month" }}
diff --git a/http/ui_test.go b/http/ui_test.go new file mode 100644 index 0000000..19ff953 --- /dev/null +++ b/http/ui_test.go @@ -0,0 +1,136 @@ +package http + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/digitalocean/tester" + "github.com/digitalocean/tester/db" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// uiTestSummaries builds one bucket per summaryState for package "pkg". +func uiTestSummaries(begin time.Time, window time.Duration) []*tester.RunSummary { + newPkg := func() *tester.PackageSummary { + return &tester.PackageSummary{ + Package: "pkg", + PassedTests: map[string][]uuid.UUID{}, + FailedTests: map[string][]uuid.UUID{}, + SkippedTests: map[string][]uuid.UUID{}, + } + } + + results := newPkg() + results.RunIDs = []uuid.UUID{uuid.New()} + results.PassedTests["TestA"] = []uuid.UUID{uuid.New()} + results.FailedTests["TestB"] = []uuid.UUID{uuid.New()} + + running := newPkg() + running.RunIDs = []uuid.UUID{uuid.New()} + running.RunningRunIDs = running.RunIDs + + errored := newPkg() + errored.ErrorRunIDs = []uuid.UUID{uuid.New()} + + active := newPkg() + active.RunIDs = []uuid.UUID{uuid.New()} + + var summaries []*tester.RunSummary + for i, ps := range []*tester.PackageSummary{results, running, errored, active, nil} { + s := &tester.RunSummary{ + Time: begin.Add(time.Duration(i) * window), + Duration: window, + PackageSummary: map[string]*tester.PackageSummary{}, + } + if ps != nil { + s.PackageSummary["pkg"] = ps + } + summaries = append(summaries, s) + } + return summaries +} + +func TestSummaryState(t *testing.T) { + summaries := uiTestSummaries(time.Now(), time.Minute) + assert.Equal(t, "results", summaryState(summaries[0].PackageSummary["pkg"])) + assert.Equal(t, "running", summaryState(summaries[1].PackageSummary["pkg"])) + assert.Equal(t, "errored", summaryState(summaries[2].PackageSummary["pkg"])) + assert.Equal(t, "active", summaryState(summaries[3].PackageSummary["pkg"])) + assert.Equal(t, "empty", summaryState(summaries[4].PackageSummary["pkg"]), "missing package is a nil *PackageSummary") + assert.Equal(t, "empty", summaryState(summaries[4])) + assert.Equal(t, "results", summaryState(summaries[0])) + assert.Equal(t, "running", summaryState(summaries[1])) + assert.Equal(t, "empty", summaryState((*tester.RunSummary)(nil))) + + // Errored takes precedence over running when there are no results. + both := &tester.PackageSummary{ + RunIDs: []uuid.UUID{uuid.New()}, RunningRunIDs: []uuid.UUID{uuid.New()}, ErrorRunIDs: []uuid.UUID{uuid.New()}, + } + assert.Equal(t, "errored", summaryState(both)) +} + +func TestUI_RendersRunSummaryStates(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockDB := db.NewMockDB(ctrl) + packages := []*tester.Package{{Name: "pkg"}} + ui := NewUIHandler(mockDB, packages) + ts := httptest.NewServer(ui) + defer ts.Close() + + begin := time.Now().Truncate(5 * time.Minute).Add(-time.Hour) + summaries := uiTestSummaries(begin, 5*time.Minute) + + get := func(path string) (int, string) { + resp, err := ts.Client().Get(ts.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, string(body) + } + + t.Run("dashboard", func(t *testing.T) { + // The dashboard loads the hour, day and month strips in parallel. + mockDB.EXPECT().ListRunSummariesInRange(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(summaries, nil).Times(3) + + status, body := get("/") + require.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, `class="summary-running"`) + assert.Contains(t, body, `class="summary-active"`) + assert.Contains(t, body, "1 running") + assert.Contains(t, body, "1 erred") + assert.Contains(t, body, "bg-success") + }) + + t.Run("run_summary", func(t *testing.T) { + mockDB.EXPECT(). + ListRunSummariesInRange(gomock.Any(), gomock.Any(), gomock.Any(), 5*time.Minute). + Return(summaries[1:2], nil) + + status, body := get(fmt.Sprintf("/run_summary?package=pkg&begin=%d&window=300", begin.Unix())) + require.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, "1 Runs (1 running)") + assert.Contains(t, body, "/runs/"+summaries[1].PackageSummary["pkg"].RunningRunIDs[0].String()) + assert.Equal(t, 1, strings.Count(body, `class="badge bg-info rounded-pill"`)) + }) + + t.Run("run_summary errored", func(t *testing.T) { + mockDB.EXPECT(). + ListRunSummariesInRange(gomock.Any(), gomock.Any(), gomock.Any(), 5*time.Minute). + Return(summaries[2:3], nil) + + status, body := get(fmt.Sprintf("/run_summary?package=pkg&begin=%d&window=300", begin.Unix())) + require.Equal(t, http.StatusOK, status, body) + assert.Contains(t, body, "/runs/"+summaries[2].PackageSummary["pkg"].ErrorRunIDs[0].String()) + }) +} diff --git a/types.go b/types.go index b858db1..2920165 100644 --- a/types.go +++ b/types.go @@ -103,6 +103,13 @@ func (o *Option) String() string { return fmt.Sprintf("-%s=%s", o.Name, o.Value) } +// RunSummary aggregates the runs and test results that fall in the time +// bucket [Time, Time+Duration), grouped by package. +// +// A run is attributed to every bucket it was executing in, from started_at to +// finished_at (or now, if it is still running), so a long run shows up in +// several consecutive buckets. Its test results are only counted in the bucket +// it started in. type RunSummary struct { Time time.Time Duration time.Duration @@ -112,7 +119,7 @@ type RunSummary struct { func (s *RunSummary) NumRuns() int { var total int for _, pkgSummary := range s.PackageSummary { - total += len(pkgSummary.RunIDs) + total += pkgSummary.NumRuns() } return total } @@ -120,7 +127,15 @@ func (s *RunSummary) NumRuns() int { func (s *RunSummary) NumErrorRuns() int { var total int for _, pkgSummary := range s.PackageSummary { - total += len(pkgSummary.ErrorRunIDs) + total += pkgSummary.NumErrorRuns() + } + return total +} + +func (s *RunSummary) NumRunningRuns() int { + var total int + for _, pkgSummary := range s.PackageSummary { + total += pkgSummary.NumRunningRuns() } return total } @@ -175,13 +190,34 @@ func (s *RunSummary) NumTotalTests() int { return passed + failed + skipped } +// PackageSummary is the per-package part of a RunSummary. +// +// RunIDs holds every run of the package that did not error and was executing +// at some point during the bucket; RunningRunIDs is the subset of those that +// has not finished yet. ErrorRunIDs holds the runs that errored; they are +// disjoint from RunIDs and their tests are not counted. The test maps are +// keyed by test name and only contain tests of runs that started in this +// bucket. type PackageSummary struct { - Package string - RunIDs []uuid.UUID - ErrorRunIDs []uuid.UUID - PassedTests map[string][]uuid.UUID - FailedTests map[string][]uuid.UUID - SkippedTests map[string][]uuid.UUID + Package string + RunIDs []uuid.UUID + RunningRunIDs []uuid.UUID + ErrorRunIDs []uuid.UUID + PassedTests map[string][]uuid.UUID + FailedTests map[string][]uuid.UUID + SkippedTests map[string][]uuid.UUID +} + +func (s *PackageSummary) NumRuns() int { + return len(s.RunIDs) +} + +func (s *PackageSummary) NumErrorRuns() int { + return len(s.ErrorRunIDs) +} + +func (s *PackageSummary) NumRunningRuns() int { + return len(s.RunningRunIDs) } func (s *PackageSummary) NumPassedTests() int {