blob: ff93e6cb8466e839ae5d4830b148b5b02d6bcf16 [file] [log] [blame]
// Copyright 2024 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package treemap
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"dawn.googlesource.com/dawn/tools/src/auth"
"dawn.googlesource.com/dawn/tools/src/browser"
"dawn.googlesource.com/dawn/tools/src/cmd/cts/common"
"dawn.googlesource.com/dawn/tools/src/container"
"dawn.googlesource.com/dawn/tools/src/cts/query"
"dawn.googlesource.com/dawn/tools/src/fileutils"
"dawn.googlesource.com/dawn/tools/src/subcmd"
luciauth "go.chromium.org/luci/auth"
"go.chromium.org/luci/auth/client/authcli"
)
func init() {
common.Register(&cmd{})
}
type cmd struct {
flags struct {
source common.ResultSource
auth authcli.Flags
keepAlive bool
}
}
func (cmd) Name() string {
return "treemap"
}
func (cmd) Desc() string {
return `displays a treemap visualization of the CTS tests cases
mode:
cases - displays a treemap of the test cases, visualizing both the
distribution of cases (spatially) and the number of parameterized
cases per test (color).
timing - displays a treemap of the time taken by the CTS bots, visualizing
both the total c of cases (spatially) and the number of parameterized
cases per test (color).`
}
func (c *cmd) RegisterFlags(ctx context.Context, cfg common.Config) ([]string, error) {
c.flags.source.RegisterFlags(cfg)
c.flags.auth.Register(flag.CommandLine, auth.DefaultAuthOptions())
flag.BoolVar(&c.flags.keepAlive, "keep-alive", false, "keep the server alive after the page has been closed")
return []string{"[cases | timing]"}, nil
}
func (c *cmd) Run(ctx context.Context, cfg common.Config) error {
ctx, stop := context.WithCancel(context.Background())
defer stop()
// Are we visualizing cases, or timings?
var data string
var err error
switch flag.Arg(0) {
case "case", "cases":
data, err = loadCasesData()
case "time", "times", "timing":
// Validate command line arguments
auth, err := c.flags.auth.Options()
if err != nil {
return fmt.Errorf("failed to obtain authentication options: %w", err)
}
data, err = loadTimingData(ctx, c.flags.source, cfg, auth)
default:
err = subcmd.ErrInvalidCLA
}
if err != nil {
return err
}
// Kick the server
handler := http.NewServeMux()
handler.HandleFunc("/index.html", func(w http.ResponseWriter, r *http.Request) {
f, err := os.Open(filepath.Join(fileutils.ThisDir(), "treemap.html"))
if err != nil {
fmt.Fprint(w, "file not found")
w.WriteHeader(http.StatusNotFound)
return
}
defer f.Close()
io.Copy(w, f)
})
handler.HandleFunc("/data.json", func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, strings.NewReader(data))
})
handler.HandleFunc("/viewer.closed", func(w http.ResponseWriter, r *http.Request) {
if !c.flags.keepAlive {
stop()
}
})
const port = 9393
url := fmt.Sprintf("http://localhost:%v/index.html", port)
server := &http.Server{Addr: fmt.Sprint(":", port), Handler: handler}
go server.ListenAndServe()
browser.Open(url)
<-ctx.Done()
err = server.Shutdown(ctx)
switch err {
case nil, context.Canceled:
return nil
default:
return err
}
}
// loadCasesData creates the JSON payload for a cases visualization
func loadCasesData() (string, error) {
testListPath := filepath.Join(fileutils.DawnRoot(), common.TestListRelPath)
file, err := os.Open(testListPath)
if err != nil {
return "", fmt.Errorf("failed to open test list: %w", err)
}
defer file.Close()
queryCounts := container.NewMap[string, int]()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if name := strings.TrimSpace(scanner.Text()); name != "" {
q := query.Parse(name)
q.Cases = "" // Remove parameterization
for name = q.String(); name != ""; name = parentOf(name) {
queryCounts[name] = queryCounts[name] + 1
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("failed to parse test list: %w", err)
}
// https://developers.google.com/chart/interactive/docs/gallery/treemap#data-format
data := &strings.Builder{}
fmt.Fprint(data, `{`)
fmt.Fprint(data, `"desc":"Treemap visualization of the CTS test cases.<br>Area represents total number of test cases.<br>Color represents the number of parameterized test cases for a single test.",`)
fmt.Fprint(data, `"limit": 5000,`)
fmt.Fprint(data, `"data":[`)
fmt.Fprint(data, `["Query", "Parent", "Number of tests", "Color"],`)
fmt.Fprint(data, `["root", null, 0, 0]`)
for _, query := range queryCounts.Keys() {
fmt.Fprint(data, ",")
count := queryCounts[query]
if err := json.NewEncoder(data).Encode([]any{query, parentOfOrRoot(query), count, count}); err != nil {
return "", err
}
}
fmt.Fprintln(data, `]}`)
return data.String(), nil
}
type durations struct {
sum time.Duration
count int
}
func (d durations) add(n time.Duration) durations {
return durations{d.sum + n, d.count + 1}
}
func (d durations) average() time.Duration {
if d.count == 0 {
return 0
}
return time.Duration(float64(d.sum) / float64(d.count))
}
// loadTimingData creates the JSON payload for timing visualization
func loadTimingData(ctx context.Context, source common.ResultSource, cfg common.Config, auth luciauth.Options) (string, error) {
// Obtain the results
results, err := source.GetResults(ctx, cfg, auth)
if err != nil {
return "", err
}
queryTimes := container.NewMap[string, durations]()
for _, mode := range results {
for _, result := range mode {
q := result.Query
q.Cases = "" // Remove parameterization
for name := q.String(); name != ""; name = parentOf(name) {
queryTimes[name] = queryTimes[name].add(result.Duration)
}
}
}
// https://developers.google.com/chart/interactive/docs/gallery/treemap#data-format
data := &strings.Builder{}
fmt.Fprint(data, `{`)
fmt.Fprint(data, `"desc":"Treemap visualization of the CTS timings.<br>Area represents total time taken by the test cases.<br>Color represents average time take by the non-parameterized test case.",`)
fmt.Fprint(data, `"limit": 1000,`)
fmt.Fprint(data, `"data":[`)
fmt.Fprint(data, `["Query", "Parent", "Time (ms)", "Color"],`)
fmt.Fprint(data, `["root", null, 0, 0]`)
for _, query := range queryTimes.Keys() {
fmt.Fprint(data, ",")
d := queryTimes[query].average()
if err := json.NewEncoder(data).Encode([]any{query, parentOfOrRoot(query), d.Milliseconds(), d.Milliseconds()}); err != nil {
return "", err
}
}
fmt.Fprintln(data, `]}`)
return data.String(), nil
}
func parentOf(query string) string {
if n := strings.LastIndexAny(query, ",:"); n > 0 {
return query[:n]
}
return ""
}
func parentOfOrRoot(query string) string {
if parent := parentOf(query); parent != "" {
return parent
}
return "root"
}