tools run-cts: Add --coverage flag
Displays a per-test coverage viewer in your browser
Change-Id: I0b808bfadf01dab0540143760580cd7ca680e93b
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/113644
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: Antonio Maiorano <amaiorano@google.com>
Commit-Queue: Ben Clayton <bclayton@google.com>
Reviewed-by: Dan Sinclair <dsinclair@chromium.org>
diff --git a/tools/src/cmd/run-cts/main.go b/tools/src/cmd/run-cts/main.go
index 09e8863..df6aef0 100644
--- a/tools/src/cmd/run-cts/main.go
+++ b/tools/src/cmd/run-cts/main.go
@@ -39,7 +39,9 @@
"time"
"unicode/utf8"
+ "dawn.googlesource.com/dawn/tools/src/cov"
"dawn.googlesource.com/dawn/tools/src/fileutils"
+ "dawn.googlesource.com/dawn/tools/src/git"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
)
@@ -65,8 +67,7 @@
}
var (
- colors bool
- mainCtx context.Context
+ colors bool
)
// ANSI escape sequences
@@ -99,7 +100,7 @@
return nil
}
-func makeMainCtx() context.Context {
+func makeCtx() context.Context {
ctx, cancel := context.WithCancel(context.Background())
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
@@ -112,7 +113,7 @@
}
func run() error {
- mainCtx = makeMainCtx()
+ ctx := makeCtx()
colors = os.Getenv("TERM") != "dumb" ||
isatty.IsTerminal(os.Stdout.Fd()) ||
@@ -128,8 +129,8 @@
backendDefault = "vulkan"
}
- var dawnNode, cts, node, npx, resultsPath, expectationsPath, logFilename, backend string
- var printStdout, verbose, isolated, build, dumpShaders bool
+ var dawnNode, cts, node, npx, resultsPath, expectationsPath, logFilename, backend, coverageFile string
+ var printStdout, verbose, isolated, build, dumpShaders, genCoverage bool
var numRunners int
var flags dawnNodeFlags
flag.StringVar(&dawnNode, "dawn-node", "", "path to dawn.node module")
@@ -149,6 +150,8 @@
flag.StringVar(&backend, "backend", backendDefault, "backend to use: default|null|webgpu|d3d11|d3d12|metal|vulkan|opengl|opengles."+
" set to 'vulkan' if VK_ICD_FILENAMES environment variable is set, 'default' otherwise")
flag.BoolVar(&dumpShaders, "dump-shaders", false, "dump WGSL shaders. Enables --verbose")
+ flag.BoolVar(&genCoverage, "coverage", false, "displays coverage data. Enables --isolated")
+ flag.StringVar(&coverageFile, "export-coverage", "", "write coverage data to the given path")
flag.Parse()
// Create a thread-safe, color supporting stdout wrapper.
@@ -233,6 +236,7 @@
npx: npx,
dawnNode: dawnNode,
cts: cts,
+ tmpDir: filepath.Join(os.TempDir(), "dawn-cts"),
flags: flags,
results: testcaseStatuses{},
evalScript: func(main string) string {
@@ -242,6 +246,28 @@
colors: colors,
}
+ if coverageFile != "" {
+ r.coverageFile = coverageFile
+ genCoverage = true
+ }
+
+ if genCoverage {
+ isolated = true
+ llvmCov, err := exec.LookPath("llvm-cov")
+ if err != nil {
+ return fmt.Errorf("failed to find LLVM, required for --coverage")
+ }
+ turboCov := filepath.Join(filepath.Dir(dawnNode), "turbo-cov"+fileutils.ExeExt)
+ if !fileutils.IsExe(turboCov) {
+ turboCov = ""
+ }
+ r.covEnv = &cov.Env{
+ LLVMBin: filepath.Dir(llvmCov),
+ Binary: dawnNode,
+ TurboCov: turboCov,
+ }
+ }
+
if logFilename != "" {
writer, err := os.Create(logFilename)
if err != nil {
@@ -305,19 +331,19 @@
if isolated {
fmt.Fprintln(stdout, "Running in parallel isolated...")
fmt.Fprintf(stdout, "Testing %d test cases...\n", len(r.testcases))
- if err := r.runParallelIsolated(); err != nil {
+ if err := r.runParallelIsolated(ctx); err != nil {
return err
}
} else {
fmt.Fprintln(stdout, "Running in parallel with server...")
fmt.Fprintf(stdout, "Testing %d test cases...\n", len(r.testcases))
- if err := r.runParallelWithServer(); err != nil {
+ if err := r.runParallelWithServer(ctx); err != nil {
return err
}
}
} else {
fmt.Fprintln(stdout, "Running serially...")
- if err := r.runSerially(query); err != nil {
+ if err := r.runSerially(ctx, query); err != nil {
return err
}
}
@@ -385,18 +411,24 @@
}
type runner struct {
- numRunners int
- printStdout bool
- verbose bool
- node, npx, dawnNode, cts string
- flags dawnNodeFlags
- evalScript func(string) string
- testcases []string
- expectations testcaseStatuses
- results testcaseStatuses
- log logger
- stdout io.WriteCloser
- colors bool // Colors enabled?
+ numRunners int
+ printStdout bool
+ verbose bool
+ node string
+ npx string
+ dawnNode string
+ cts string
+ tmpDir string
+ flags dawnNodeFlags
+ covEnv *cov.Env
+ coverageFile string
+ evalScript func(string) string
+ testcases []string
+ expectations testcaseStatuses
+ results testcaseStatuses
+ log logger
+ stdout io.WriteCloser
+ colors bool // Colors enabled?
}
// scanSourceTimestamps scans all the .js and .ts files in all subdirectories of
@@ -562,7 +594,7 @@
// runParallelWithServer() starts r.numRunners instances of the CTS server test
// runner, and issues test run requests to those servers, concurrently.
-func (r *runner) runParallelWithServer() error {
+func (r *runner) runParallelWithServer(ctx context.Context) error {
// Create a chan of test indices.
// This will be read by the test runner goroutines.
caseIndices := make(chan int, len(r.testcases))
@@ -582,7 +614,7 @@
wg.Add(1)
go func() {
defer wg.Done()
- if err := r.runServer(id, caseIndices, results); err != nil {
+ if err := r.runServer(ctx, id, caseIndices, results); err != nil {
results <- result{
status: fail,
error: fmt.Errorf("Test server error: %w", err),
@@ -591,8 +623,7 @@
}()
}
- r.streamResults(wg, results)
- return nil
+ return r.streamResults(ctx, wg, results)
}
// runServer starts a test runner server instance, takes case indices from
@@ -600,7 +631,7 @@
// The result of the test run is written to the results chan.
// Once the caseIndices chan has been closed, the server is stopped and
// runServer returns.
-func (r *runner) runServer(id int, caseIndices <-chan int, results chan<- result) error {
+func (r *runner) runServer(ctx context.Context, id int, caseIndices <-chan int, results chan<- result) error {
var port int
testCaseLog := &bytes.Buffer{}
@@ -627,7 +658,6 @@
args = append(args, "--gpu-provider-flag", f)
}
- ctx := mainCtx
cmd := exec.CommandContext(ctx, r.node, args...)
writer := io.Writer(testCaseLog)
@@ -736,7 +766,7 @@
// testcase in a separate process. This reduces possibility of state leakage
// between tests.
// Up to r.numRunners tests will be run concurrently.
-func (r *runner) runParallelIsolated() error {
+func (r *runner) runParallelIsolated(ctx context.Context) error {
// Create a chan of test indices.
// This will be read by the test runner goroutines.
caseIndices := make(chan int, len(r.testcases))
@@ -753,18 +783,28 @@
wg := &sync.WaitGroup{}
for i := 0; i < r.numRunners; i++ {
wg.Add(1)
+
+ profraw := ""
+ if r.covEnv != nil {
+ profraw = filepath.Join(r.tmpDir, fmt.Sprintf("cts-%v.profraw", i))
+ defer os.Remove(profraw)
+ }
+
go func() {
defer wg.Done()
for idx := range caseIndices {
- res := r.runTestcase(r.testcases[idx])
+ res := r.runTestcase(ctx, r.testcases[idx], profraw)
res.index = idx
results <- res
+
+ if err := ctx.Err(); err != nil {
+ return
+ }
}
}()
}
- r.streamResults(wg, results)
- return nil
+ return r.streamResults(ctx, wg, results)
}
// streamResults reads from the chan 'results', printing the results in test-id
@@ -772,7 +812,7 @@
// automatically close the 'results' chan.
// Once all the results have been printed, a summary will be printed and the
// function will return.
-func (r *runner) streamResults(wg *sync.WaitGroup, results chan result) {
+func (r *runner) streamResults(ctx context.Context, wg *sync.WaitGroup, results chan result) error {
// Create another goroutine to close the results chan when all the runner
// goroutines have finished.
start := time.Now()
@@ -803,6 +843,11 @@
progressUpdateRate = time.Second
}
+ var covTree *cov.Tree
+ if r.covEnv != nil {
+ covTree = &cov.Tree{}
+ }
+
for res := range results {
r.log.logResults(res)
r.results[res.testcase] = res.status
@@ -839,6 +884,10 @@
if time.Since(lastStatusUpdate) > progressUpdateRate {
updateProgress()
}
+
+ if res.coverage != nil {
+ covTree.Add(splitTestCaseForCoverage(res.testcase), res.coverage)
+ }
}
fmt.Fprint(r.stdout, ansiProgressBar(animFrame, numTests, numByExpectedStatus))
@@ -888,14 +937,53 @@
fmt.Fprintln(r.stdout)
}
+ if covTree != nil {
+ // Obtain the current git revision
+ revision := "HEAD"
+ if g, err := git.New(""); err == nil {
+ if r, err := g.Open(fileutils.DawnRoot()); err == nil {
+ if l, err := r.Log(&git.LogOptions{From: "HEAD", To: "HEAD"}); err == nil {
+ revision = l[0].Hash.String()
+ }
+ }
+ }
+
+ if r.coverageFile != "" {
+ file, err := os.Create(r.coverageFile)
+ if err != nil {
+ return fmt.Errorf("failed to create the coverage file: %w", err)
+ }
+ defer file.Close()
+ if err := covTree.Encode(revision, file); err != nil {
+ return fmt.Errorf("failed to encode coverage file: %w", err)
+ }
+
+ fmt.Fprintln(r.stdout)
+ fmt.Fprintln(r.stdout, "Coverage data written to "+r.coverageFile)
+ return nil
+ }
+
+ cov := &bytes.Buffer{}
+ if err := covTree.Encode(revision, cov); err != nil {
+ return fmt.Errorf("failed to encode coverage file: %w", err)
+ }
+ return showCoverageServer(ctx, cov.Bytes(), r.stdout)
+ }
+
+ return nil
}
// runSerially() calls the CTS test runner to run the test query in a single
// process.
// TODO(bclayton): Support comparing against r.expectations
-func (r *runner) runSerially(query string) error {
+func (r *runner) runSerially(ctx context.Context, query string) error {
+ profraw := ""
+ if r.covEnv != nil {
+ profraw = filepath.Join(r.tmpDir, "cts.profraw")
+ }
+
start := time.Now()
- result := r.runTestcase(query)
+ result := r.runTestcase(ctx, query, profraw)
timeTaken := time.Since(start)
if r.verbose {
@@ -942,12 +1030,13 @@
status status
message string
error error
+ coverage *cov.Coverage
}
// runTestcase() runs the CTS testcase with the given query, returning the test
// result.
-func (r *runner) runTestcase(query string) result {
- ctx, cancel := context.WithTimeout(mainCtx, testTimeout)
+func (r *runner) runTestcase(ctx context.Context, query string, profraw string) result {
+ ctx, cancel := context.WithTimeout(ctx, testTimeout)
defer cancel()
args := []string{
@@ -973,27 +1062,52 @@
cmd := exec.CommandContext(ctx, r.node, args...)
cmd.Dir = r.cts
+ if profraw != "" {
+ cmd.Env = os.Environ()
+ cmd.Env = append(cmd.Env, cov.RuntimeEnv(cmd.Env, profraw))
+ }
+
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
+
msg := buf.String()
+ res := result{testcase: query,
+ status: pass,
+ message: msg,
+ error: err,
+ }
+
+ if r.covEnv != nil {
+ coverage, covErr := r.covEnv.Import(profraw)
+ if covErr != nil {
+ err = fmt.Errorf("could not import coverage data: %v", err)
+ }
+ res.coverage = coverage
+ }
+
switch {
case errors.Is(err, context.DeadlineExceeded):
- return result{testcase: query, status: timeout, message: msg, error: err}
- case err != nil:
- break
- case strings.Contains(msg, "[fail]"):
- return result{testcase: query, status: fail, message: msg}
+ res.status = timeout
+ case err != nil, strings.Contains(msg, "[fail]"):
+ res.status = fail
case strings.Contains(msg, "[warn]"):
- return result{testcase: query, status: warn, message: msg}
+ res.status = warn
case strings.Contains(msg, "[skip]"):
- return result{testcase: query, status: skip, message: msg}
- case strings.Contains(msg, "[pass]"), err == nil:
- return result{testcase: query, status: pass, message: msg}
+ res.status = skip
+ case strings.Contains(msg, "[pass]"):
+ break
+ default:
+ res.status = fail
+ msg += "\ncould not parse test output"
}
- return result{testcase: query, status: fail, message: fmt.Sprint(msg, err), error: err}
+
+ if res.error != nil {
+ res.message = fmt.Sprint(res.message, res.error)
+ }
+ return res
}
// filterTestcases returns in with empty strings removed
@@ -1251,3 +1365,83 @@
close(w.data)
return <-w.err
}
+
+func splitTestCaseForCoverage(testcase string) []string {
+ out := []string{}
+ s := 0
+ for e, r := range testcase {
+ switch r {
+ case ':', '.':
+ out = append(out, testcase[s:e])
+ s = e
+ }
+ }
+ return out
+}
+
+// showCoverageServer starts a localhost http server to display the coverage data, launching a
+// browser if one can be found. Blocks until the context is cancelled.
+func showCoverageServer(ctx context.Context, covData []byte, stdout io.Writer) error {
+ const port = "9392"
+ url := fmt.Sprintf("http://localhost:%v/index.html", port)
+
+ handler := http.NewServeMux()
+ handler.HandleFunc("/index.html", func(w http.ResponseWriter, r *http.Request) {
+ f, err := os.Open(filepath.Join(fileutils.ThisDir(), "view-coverage.html"))
+ if err != nil {
+ fmt.Fprint(w, "file not found")
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ defer f.Close()
+ io.Copy(w, f)
+ })
+ handler.HandleFunc("/coverage.dat", func(w http.ResponseWriter, r *http.Request) {
+ io.Copy(w, bytes.NewReader(covData))
+ })
+ handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ rel := r.URL.Path
+ if r.URL.Path == "" {
+ http.Redirect(w, r, url, http.StatusSeeOther)
+ return
+ }
+ if strings.Contains(rel, "..") {
+ w.WriteHeader(http.StatusBadRequest)
+ fmt.Fprint(w, "file path must not contain '..'")
+ return
+ }
+ f, err := os.Open(filepath.Join(fileutils.DawnRoot(), r.URL.Path))
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ fmt.Fprintf(w, "file '%v' not found", r.URL.Path)
+ return
+ }
+ defer f.Close()
+ io.Copy(w, f)
+ })
+
+ server := &http.Server{Addr: ":" + port, Handler: handler}
+ go server.ListenAndServe()
+
+ fmt.Fprintln(stdout)
+ fmt.Fprintln(stdout, "Serving coverage view at "+blue+url+ansiReset)
+
+ openBrowser(url)
+
+ <-ctx.Done()
+ return server.Shutdown(ctx)
+}
+
+// openBrowser launches a browser to open the given url
+func openBrowser(url string) error {
+ switch runtime.GOOS {
+ case "linux":
+ return exec.Command("xdg-open", url).Start()
+ case "windows":
+ return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
+ case "darwin":
+ return exec.Command("open", url).Start()
+ default:
+ return fmt.Errorf("unsupported platform")
+ }
+}
diff --git a/tools/src/cmd/run-cts/view-coverage.html b/tools/src/cmd/run-cts/view-coverage.html
new file mode 100644
index 0000000..f7333bb
--- /dev/null
+++ b/tools/src/cmd/run-cts/view-coverage.html
@@ -0,0 +1,578 @@
+<!doctype html>
+<!--
+ Copyright 2022 The Dawn and Tint Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+<html>
+
+<head>
+ <title>Dawn Code Coverage viewer</title>
+
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.52.0/codemirror.min.js"></script>
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.52.0/theme/seti.min.css">
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.52.0/codemirror.min.css">
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.52.0/mode/clike/clike.min.js"></script>
+ <script src=https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako.min.js></script>
+
+ <style>
+ ::-webkit-scrollbar {
+ background-color: #30353530;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-color: #80858050;
+ }
+
+ ::-webkit-scrollbar-corner {
+ background-color: #00000000;
+ }
+
+ .frame {
+ display: flex;
+ left: 0px;
+ right: 0px;
+ top: 0px;
+ bottom: 0px;
+ position: absolute;
+ font-family: monospace;
+ background-color: #151515;
+ color: #c0b070;
+ }
+
+ .left-pane {
+ flex: 1;
+ }
+
+ .center-pane {
+ flex: 3;
+ min-width: 0;
+ min-height: 0;
+ }
+
+ .top-pane {
+ flex: 1;
+ overflow: scroll;
+ }
+
+ .v-flex {
+ display: flex;
+ height: 100%;
+ flex-direction: column;
+ }
+
+ .file-tree {
+ font-size: small;
+ overflow: auto;
+ padding: 5px;
+ }
+
+ .test-tree {
+ font-size: small;
+ overflow: auto;
+ padding: 5px;
+ }
+
+ .CodeMirror {
+ flex: 3;
+ height: 100%;
+ border: 1px solid #eee;
+ }
+
+ .file-div {
+ margin: 0px;
+ white-space: nowrap;
+ padding: 2px;
+ margin-top: 1px;
+ margin-bottom: 1px;
+ }
+
+ .file-div:hover {
+ background-color: #303030;
+ cursor: pointer;
+ }
+
+ .file-div.selected {
+ background-color: #505050;
+ color: #f0f0a0;
+ cursor: pointer;
+ }
+
+ .test-name {
+ margin: 0px;
+ white-space: nowrap;
+ padding: 2px;
+ margin-top: 1px;
+ margin-bottom: 1px;
+ }
+
+ .file-coverage {
+ color: black;
+ width: 20pt;
+ padding-right: 3pt;
+ padding-left: 3px;
+ margin-right: 5pt;
+ display: inline-block;
+ text-align: center;
+ border-radius: 5px;
+ }
+
+ .with-coverage {
+ background-color: #20d04080;
+ border-width: 0px 0px 0px 0px;
+ }
+
+ .with-coverage-start {
+ border-left: solid 1px;
+ border-color: #20f02080;
+ margin-left: -1px;
+ }
+
+ .with-coverage-end {
+ border-right: solid 1px;
+ border-color: #20f02080;
+ margin-right: -1px;
+ }
+
+ .without-coverage {
+ background-color: #d0204080;
+ border-width: 0px 0px 0px 0px;
+ }
+
+ .without-coverage-start {
+ border-left: solid 1px;
+ border-color: #f0202080;
+ margin-left: -1px;
+ }
+
+ .without-coverage-end {
+ border-right: solid 1px;
+ border-color: #f0202080;
+ margin-right: -1px;
+ }
+ </style>
+</head>
+
+<body>
+ <div class="frame">
+ <div id="file_tree" class="left-pane file-tree"></div>
+ <div class="center-pane">
+ <div id="source" class="v-flex">
+ <div class="top-pane">
+ <div class="test-tree" id="test_tree"></div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <script>
+ // "Download" the coverage.dat file if the user presses ctrl-s
+ document.addEventListener('keydown', e => {
+ if (e.ctrlKey && e.key === 's') {
+ e.preventDefault();
+ window.open("coverage.dat");
+ }
+ });
+
+ let current = {
+ file: "",
+ start_line: 0,
+ start_column: 0,
+ end_line: 0,
+ end_column: 0,
+ };
+
+ let pending = { ...current };
+ {
+ let url = new URL(location.href);
+ let query_string = url.search;
+ let search_params = new URLSearchParams(query_string);
+ var f = search_params.get('f');
+ var s = search_params.get('s');
+ var e = search_params.get('e');
+ if (f) {
+ pending.file = f; // f.replace(/\./g, '/');
+ }
+ if (s) {
+ s = s.split('.');
+ pending.start_line = s.length > 0 ? parseInt(s[0]) : 0;
+ pending.start_column = s.length > 1 ? parseInt(s[1]) : 0;
+ }
+ if (e) {
+ e = e.split('.');
+ pending.end_line = e.length > 0 ? parseInt(e[0]) : 0;
+ pending.end_column = e.length > 1 ? parseInt(e[1]) : 0;
+ }
+ };
+
+ let set_location = (file, start_line, start_column, end_line, end_column) => {
+ current.file = file;
+ current.start_line = start_line;
+ current.start_column = start_column;
+ current.end_line = end_line;
+ current.end_column = end_column;
+
+ let url = new URL(location.href);
+ let query_string = url.search;
+ // Don't use URLSearchParams, as it will unnecessarily escape
+ // characters, such as '/'.
+ url.search = "f=" + file +
+ "&s=" + start_line + "." + end_line +
+ "&e=" + end_line + "." + end_column;
+ window.history.replaceState(null, "", url.toString());
+ };
+
+ let before = (line, col, span) => {
+ if (line < span[0]) { return true; }
+ if (line == span[0]) { return col < span[1]; }
+ return false;
+ };
+
+ let after = (line, col, span) => {
+ if (line > span[2]) { return true; }
+ if (line == span[2]) { return col > span[3]; }
+ return false;
+ };
+
+ let intersects = (span, from, to) => {
+ if (!before(to.line + 1, to.ch + 1, span) &&
+ !after(from.line + 1, from.ch + 1, span)) {
+ return true;
+ }
+ return false;
+ };
+
+ let el_file_tree = document.getElementById("file_tree");
+ let el_test_tree = document.getElementById("test_tree");
+ let el_source = CodeMirror(document.getElementById("source"), {
+ lineNumbers: true,
+ theme: "seti",
+ mode: "text/x-c++src",
+ readOnly: true,
+ });
+
+ window.onload = function () {
+ el_source.doc.setValue("// Loading... ");
+ fetch("coverage.dat").then(response =>
+ response.arrayBuffer()
+ ).then(compressed =>
+ pako.inflate(new Uint8Array(compressed))
+ ).then(decompressed =>
+ JSON.parse(new TextDecoder("utf-8").decode(decompressed))
+ ).then(json => {
+ el_source.doc.setValue("// Select file from the left... ");
+
+ let revision = json.r;
+ let names = json.n;
+ let tests = json.t;
+ let spans = json.s;
+ let files = json.f;
+
+ let glob_group = (file, groupID, span_ids) => {
+ while (true) {
+ let group = file.g[groupID];
+ group.s.forEach(span_id => span_ids.add(span_id));
+ if (!group.e) {
+ break;
+ }
+ groupID = group.e;
+ };
+ };
+
+ let coverage_spans = (file, data, span_ids) => {
+ if (data.g != undefined) {
+ glob_group(file, data.g, span_ids);
+ }
+ if (data.s != undefined) {
+ data.s.forEach(span_id => span_ids.add(span_id));
+ }
+ };
+
+ let glob_node = (file, nodes, span_ids) => {
+ nodes.forEach(node => {
+ let data = node[1];
+ coverage_spans(file, data, span_ids);
+ if (data.c) {
+ glob_node(file, data.c, span_ids);
+ }
+ });
+ };
+
+ let markup = file => {
+ if (file.u) {
+ for (span of file.u) {
+ el_source.doc.markText(
+ { "line": span[0] - 1, "ch": span[1] - 1 },
+ { "line": span[2] - 1, "ch": span[3] - 1 },
+ {
+ // inclusiveLeft: true,
+ className: "without-coverage",
+ startStyle: "without-coverage-start",
+ endStyle: "without-coverage-end",
+ });
+ }
+ }
+ let span_ids = new Set();
+ glob_node(file, file.c, span_ids);
+ el_source.operation(() => {
+ span_ids.forEach((span_id) => {
+ let span = spans[span_id];
+ el_source.doc.markText(
+ { "line": span[0] - 1, "ch": span[1] - 1 },
+ { "line": span[2] - 1, "ch": span[3] - 1 },
+ {
+ // inclusiveLeft: true,
+ className: "with-coverage",
+ startStyle: "with-coverage-start",
+ endStyle: "with-coverage-end",
+ });
+ });
+ });
+ };
+
+ let NONE_OVERLAP = 0;
+ let ALL_OVERLAP = 1;
+ let SOME_OVERLAP = 2;
+
+ let gather_overlaps = (parent, file, coverage_nodes, from, to) => {
+ if (!coverage_nodes) { return; }
+
+ // Start by populating all the children nodes from the full
+ // test lists. This includes nodes that do not have child
+ // coverage data.
+ for (var index = 0; index < parent.test.length; index++) {
+ if (parent.children.has(index)) { continue; }
+
+ let test_node = parent.test[index];
+ let test_name_id = test_node[0];
+ let test_name = names[test_name_id];
+ let test_children = test_node[1];
+
+ let node = {
+ test: test_children,
+ name: parent.name ? parent.name + test_name : test_name,
+ overlaps: new Map(parent.overlaps), // map: span_id -> OVERLAP
+ children: new Map(), // map: index -> struct
+ is_leaf: test_children.length == 0,
+ };
+ parent.children.set(index, node);
+ }
+
+ // Now update the children that do have coverage data.
+ for (const coverage_node of coverage_nodes) {
+ let index = coverage_node[0];
+ let coverage = coverage_node[1];
+ let node = parent.children.get(index);
+
+ let span_ids = new Set();
+ coverage_spans(file, coverage, span_ids);
+
+ // Update the node overlaps based on the coverage spans.
+ for (const span_id of span_ids) {
+ if (intersects(spans[span_id], from, to)) {
+ let overlap = parent.overlaps.get(span_id) || NONE_OVERLAP;
+ overlap = (overlap == NONE_OVERLAP) ? ALL_OVERLAP : NONE_OVERLAP;
+ node.overlaps.set(span_id, overlap);
+ }
+ }
+
+ // Generate the child nodes.
+ gather_overlaps(node, file, coverage.c, from, to);
+
+ // Gather all the spans used by the children.
+ let all_spans = new Set();
+ for (const [_, child] of node.children) {
+ for (const [span, _] of child.overlaps) {
+ all_spans.add(span);
+ }
+ }
+
+ // Update the node.overlaps based on the child overlaps.
+ for (const span of all_spans) {
+ let overlap = undefined;
+ for (const [_, child] of node.children) {
+ let child_overlap = child.overlaps.get(span);
+ child_overlap = (child_overlap == undefined) ? NONE_OVERLAP : child_overlap;
+ if (overlap == undefined) {
+ overlap = child_overlap;
+ } else {
+ overlap = (child_overlap == overlap) ? overlap : SOME_OVERLAP
+ }
+ }
+ node.overlaps.set(span, overlap);
+ }
+
+ // If all the node.overlaps are NONE_OVERLAP or ALL_OVERLAP
+ // then there's no point holding on to the children -
+ // we know all transitive children either fully overlap
+ // or don't at all.
+ let some_overlap = false;
+ for (const [_, overlap] of node.overlaps) {
+ if (overlap == SOME_OVERLAP) {
+ some_overlap = true;
+ break;
+ }
+ }
+
+ if (!some_overlap) {
+ node.children = null;
+ }
+ }
+ };
+
+ let gather_tests = (file, coverage_nodes, test_nodes, from, to) => {
+ let out = [];
+
+ let traverse = (parent) => {
+ for (const [idx, node] of parent.children) {
+ let do_traversal = false;
+ let do_add = false;
+
+ for (const [_, overlap] of node.overlaps) {
+ switch (overlap) {
+ case SOME_OVERLAP:
+ do_traversal = true;
+ break;
+ case ALL_OVERLAP:
+ do_add = true;
+ break;
+ }
+ }
+
+ if (do_add) {
+ out.push(node.name + (node.is_leaf ? "" : "*"));
+ } else if (do_traversal) {
+ traverse(node);
+ }
+ }
+ };
+
+ let tree = {
+ test: test_nodes,
+ overlaps: new Map(), // map: span_id -> OVERLAP
+ children: new Map(), // map: index -> struct
+ };
+
+ gather_overlaps(tree, file, coverage_nodes, from, to);
+
+ traverse(tree);
+
+ return out;
+ };
+
+ let update_selection = (from, to) => {
+ if (from.line > to.line || (from.line == to.line && from.ch > to.ch)) {
+ let tmp = from;
+ from = to;
+ to = tmp;
+ }
+
+ let file = files[current.file];
+ let filtered = gather_tests(file, file.c, tests, from, to);
+ el_test_tree.innerHTML = "";
+ filtered.forEach(test_name => {
+ let element = document.createElement('p');
+ element.className = "test-name";
+ element.innerText = test_name;
+ el_test_tree.appendChild(element);
+ });
+ };
+
+ let load_source = (path) => {
+ if (!files[path]) { return; }
+
+ for (let i = 0; i < el_file_tree.childNodes.length; i++) {
+ let el = el_file_tree.childNodes[i];
+ if (el.path == path) {
+ el.classList.add("selected");
+ } else {
+ el.classList.remove("selected");
+ }
+ }
+ el_source.doc.setValue("// Loading... ");
+ fetch(`${path}`)
+ .then(response => response.text())
+ .then(source => {
+ el_source.doc.setValue(source);
+ current.file = path;
+ markup(files[path]);
+ if (pending.start_line) {
+ var start = {
+ line: pending.start_line - 1,
+ ch: pending.start_column ? pending.start_column - 1 : 0
+ };
+ var end = {
+ line: pending.end_line ? pending.end_line - 1 : pending.start_line - 1,
+ ch: pending.end_column ? pending.end_column - 1 : 0
+ };
+ el_source.doc.setSelection(start, end);
+ update_selection(start, end);
+ }
+ pending = {};
+ });
+ };
+
+ el_source.doc.on("beforeSelectionChange", (doc, selection) => {
+ if (!files[current.file]) { return; }
+
+ let range = selection.ranges[0];
+ let from = range.head;
+ let to = range.anchor;
+
+ set_location(current.file, from.line + 1, from.ch + 1, to.line + 1, to.ch + 1);
+
+ update_selection(from, to);
+ });
+
+ for (const path of Object.keys(files)) {
+ let file = files[path];
+
+ let div = document.createElement('div');
+ div.className = "file-div";
+ div.onclick = () => { pending = {}; load_source(path); }
+ div.path = path;
+ el_file_tree.appendChild(div);
+
+ let coverage = document.createElement('span');
+ coverage.className = "file-coverage";
+ if (file.p != undefined) {
+ let red = 1.0 - file.p;
+ let green = file.p;
+ let normalize = 1.0 / (red * red + green * green);
+ red *= normalize;
+ green *= normalize;
+ coverage.innerText = Math.round(file.p * 100);
+ coverage.style = "background-color: RGB(" + 255 * red + "," + 255 * green + ", 0" + ")";
+ } else {
+ coverage.innerText = "--";
+ coverage.style = "background-color: RGB(180,180,180)";
+ }
+ div.appendChild(coverage);
+
+ let filepath = document.createElement('span');
+ filepath.className = "file-path";
+ filepath.innerText = path;
+ div.appendChild(filepath);
+ }
+
+ if (pending.file) {
+ load_source(pending.file);
+ }
+ });
+ };
+
+ </script>
+</body>
+
+</html>
diff --git a/tools/src/git/git.go b/tools/src/git/git.go
index 0889b44..9af245f 100644
--- a/tools/src/git/git.go
+++ b/tools/src/git/git.go
@@ -64,6 +64,13 @@
// New returns a new Git instance
func New(exe string) (*Git, error) {
+ if exe == "" {
+ g, err := exec.LookPath("git")
+ if err != nil {
+ return nil, fmt.Errorf("failed to find git: %v", err)
+ }
+ exe = g
+ }
if _, err := os.Stat(exe); err != nil {
return nil, err
}