Repository navigation
[SDTEST-3895] Generate HTML testdrive reports #140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
67077ba
Generate linked HTML testdrive reports
anmarchenko 08f18f9
Report empty coverage entries as a tracer error
anmarchenko bd0c292
Use renamed intake fact types
anmarchenko 1b10c13
Generate portable testdrive report URLs
anmarchenko 68a4235
Keep HTML report outcomes and file URLs accurate
anmarchenko 925c772
Count individual findings in HTML testdrive reports
anmarchenko bf83efa
Simplify testdrive output and display relative paths
anmarchenko c875aed
Name discovered dependency files in testdrive previews
anmarchenko 1615d58
Show captured testdrive failure output without duplicate errors
anmarchenko 03a5540
Label the Datadog library in testdrive run details
anmarchenko 93dced6
Require five-second and five-times-median slow test thresholds
anmarchenko File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| package testdrive | ||
|
|
||
| import ( | ||
| "errors" | ||
| "fmt" | ||
| "github.com/DataDog/ddtest/internal/testdrive/intake" | ||
| "html/template" | ||
| "net/url" | ||
| "os" | ||
| "path/filepath" | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| const reportFilename = "report.html" | ||
|
|
||
| type reportRuntime struct{ Framework, Tracer string } | ||
| type reportFact struct{ Label, Value string } | ||
| type reportCard struct { | ||
| Title, Context string | ||
| Tests []intake.Test | ||
| Coverages []intake.CoverageFact | ||
| } | ||
| type reportModel struct { | ||
| Headline, Summary string | ||
| Facts []reportFact | ||
| Cards []reportCard | ||
| } | ||
|
|
||
| func writeReport(repositoryRoot, sessionDirectory string, findings intake.Facts, commandFailed bool, runtime ...reportRuntime) (string, error) { | ||
| path := filepath.Join(sessionDirectory, reportFilename) | ||
| file, err := os.Create(path) | ||
| if err != nil { | ||
| return "", fmt.Errorf("create testdrive report: %w", err) | ||
| } | ||
| executeErr := testdriveReport.Execute(file, buildReport(repositoryRoot, findings, commandFailed, runtime...)) | ||
| if err := errors.Join(executeErr, file.Close()); err != nil { | ||
| return "", fmt.Errorf("write testdrive report: %w", err) | ||
| } | ||
| return path, nil | ||
| } | ||
| func buildReport(_ string, findings intake.Facts, commandFailed bool, runtime ...reportRuntime) reportModel { | ||
| info := reportRuntime{Framework: "Test command", Tracer: "Isolated installation"} | ||
| if len(runtime) > 0 { | ||
| info = runtime[0] | ||
| } | ||
| coverage := "Not reported" | ||
| if findings.CoveredTestCount > 0 { | ||
| coverage = fmt.Sprintf("%d / %d", findings.CoveredTestCount, findings.TestCount) | ||
| } | ||
| status := passedFailed(!commandFailed) | ||
| if findings.TestEventCount == 0 { | ||
| status = "No test results received" | ||
| } | ||
| model := reportModel{Headline: "Test events received.", Summary: "No findings.", Facts: []reportFact{ | ||
| {"Test events", fmt.Sprint(findings.TestEventCount)}, | ||
| {"Tests with coverage", coverage}, | ||
| {info.Framework, status}, | ||
| {"Datadog library", info.Tracer}, | ||
| }} | ||
| if findings.TestEventCount == 0 { | ||
| model.Headline = "No test events received." | ||
| model.Summary = "Check the instrumentation setup." | ||
| } | ||
| if findings.EmptyCoverageEntryCount > 0 { | ||
| model.Cards = append(model.Cards, reportCard{ | ||
| Title: "Tracer error: empty coverage entries", | ||
| Context: fmt.Sprintf("%d coverage entries had an empty files list. Affected payloads were excluded from coverage counts. Inspect the captured traffic.", findings.EmptyCoverageEntryCount), | ||
| }) | ||
| } | ||
| if len(findings.FailedTests) > 0 { | ||
| model.Cards = append(model.Cards, reportCard{Title: "Any tests failed?", Tests: findings.FailedTests}) | ||
| } | ||
| if len(findings.FlakyTests) > 0 { | ||
| model.Cards = append(model.Cards, reportCard{Title: "Any flaky tests?", Tests: findings.FlakyTests}) | ||
| } | ||
| if len(findings.SlowTests) > 0 { | ||
| model.Cards = append(model.Cards, reportCard{Title: "Any tests slower than the others?", Context: "Median test time · " + formatDuration(findings.TestDurationMedian), Tests: findings.SlowTests}) | ||
| } | ||
| if len(findings.BroadCoverage) > 0 { | ||
| model.Cards = append(model.Cards, reportCard{Title: "Any unusually broad test coverage?", Context: fmt.Sprintf("Median covered files · %d", findings.CoveredFilesMedian), Coverages: findings.BroadCoverage}) | ||
| } | ||
| count := len(findings.ConfigurationErrors) + findings.EmptyCoverageEntryCount | ||
| for _, size := range []int{ | ||
| len(findings.FailedTests), len(findings.FlakyTests), len(findings.SlowTests), len(findings.BroadCoverage), | ||
| } { | ||
| count += size | ||
| } | ||
| if count > 0 { | ||
| model.Summary = fmt.Sprintf("%d %s.", count, plural(count, "finding", "findings")) | ||
| } | ||
| if len(findings.ConfigurationErrors) > 0 { | ||
| model.Summary += " Tracer configuration errors: " + strings.Join(findings.ConfigurationErrors, ", ") + ". Inspect the captured traffic and test output." | ||
| } | ||
| return model | ||
| } | ||
|
|
||
| var testdriveReport = template.Must(template.New("testdrive-report").Parse(`<!doctype html> | ||
| <html lang="en"><head><meta charset="utf-8"><title>DDTest report</title> | ||
| <style>body{font:16px system-ui;max-width:900px;margin:3rem auto;padding:1rem}article,section{margin:2rem 0}li{margin:.5rem 0}</style></head><body> | ||
| <h1>{{.Headline}}</h1><p>{{.Summary}}</p> | ||
| {{range .Cards}}<article class="problem-card"><h2>{{.Title}}</h2><p>{{.Context}}</p><ul>{{range .Tests}}<li>{{.Suite}} · {{.Name}}</li>{{end}}{{range .Coverages}}<li>{{.Name}} · {{.FileCount}} files</li>{{end}}</ul></article>{{end}} | ||
| <section><h2>Run details</h2><dl>{{range .Facts}}<dt>{{.Label}}</dt><dd>{{.Value}}</dd>{{end}}</dl></section> | ||
| <section><h2>Artifacts</h2><ul><li><a href="intake/">JSON traffic</a></li><li><a href="test-output.txt">Test output</a></li></ul></section></body></html>`)) | ||
|
|
||
| func testDisplayStatus(test intake.Test) (string, string) { | ||
| status := test.Status | ||
| sawPass := status == "pass" | ||
| sawFailure := status == "fail" | ||
| for _, attempt := range test.Attempts { | ||
| sawPass = sawPass || attempt.Status == "pass" | ||
| sawFailure = sawFailure || attempt.Status == "fail" | ||
| } | ||
| if sawPass && sawFailure { | ||
| return "Flaky", "attention" | ||
| } | ||
| if status == "" && len(test.Attempts) > 0 { | ||
| status = test.Attempts[len(test.Attempts)-1].Status | ||
| } | ||
| return displayStatus(status), attemptTone(status) | ||
| } | ||
|
|
||
| func attemptTone(status string) string { | ||
| if status == "pass" { | ||
| return "good" | ||
| } | ||
| return "attention" | ||
| } | ||
|
|
||
| func findingDuration(test intake.Test) time.Duration { | ||
| if test.Duration != 0 || len(test.Attempts) == 0 { | ||
| return test.Duration | ||
| } | ||
| return test.Attempts[0].Duration | ||
| } | ||
|
|
||
| func passedFailed(passed bool) string { | ||
| if passed { | ||
| return "Passed" | ||
| } | ||
| return "Failed" | ||
| } | ||
|
|
||
| func displayStatus(status string) string { | ||
| if status == "" { | ||
| return "Unknown" | ||
| } | ||
| return strings.ToUpper(status[:1]) + status[1:] | ||
| } | ||
|
|
||
| func formatDuration(duration time.Duration) string { | ||
| if duration < time.Millisecond { | ||
| return duration.Round(time.Microsecond).String() | ||
| } | ||
| return duration.Round(time.Millisecond).String() | ||
| } | ||
|
|
||
| func plural(count int, singular, plural string) string { | ||
| if count == 1 { | ||
| return singular | ||
| } | ||
| return plural | ||
| } | ||
|
|
||
| func fileURL(path string) (string, error) { | ||
| absolutePath, err := filepath.Abs(path) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| return absoluteFileURL(absolutePath), nil | ||
| } | ||
|
|
||
| func absoluteFileURL(absolutePath string) string { | ||
| slashPath := absolutePath | ||
| // A backslash is a valid filename character in an absolute Unix path. | ||
| if !strings.HasPrefix(absolutePath, "/") { | ||
| slashPath = strings.ReplaceAll(absolutePath, `\`, "/") | ||
| } | ||
| if strings.HasPrefix(absolutePath, `\\`) { | ||
| hostAndPath := strings.TrimPrefix(slashPath, "//") | ||
| host, path, _ := strings.Cut(hostAndPath, "/") | ||
| return (&url.URL{Scheme: "file", Host: host, Path: "/" + path}).String() | ||
| } | ||
| if len(slashPath) >= 2 && slashPath[1] == ':' { | ||
| slashPath = "/" + slashPath | ||
| } | ||
| return (&url.URL{Scheme: "file", Path: slashPath}).String() | ||
| } | ||
|
|
||
| func terminalLink(target, label string) string { | ||
| return "\x1b]8;;" + target + "\x1b\\" + label + "\x1b]8;;\x1b\\" | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.