• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2021 The Dawn Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// run-cts is a tool used to run the WebGPU CTS using the Dawn module for NodeJS
16package main
17
18import (
19	"bytes"
20	"context"
21	"encoding/json"
22	"errors"
23	"flag"
24	"fmt"
25	"io"
26	"io/ioutil"
27	"math"
28	"net/http"
29	"os"
30	"os/exec"
31	"os/signal"
32	"path/filepath"
33	"regexp"
34	"runtime"
35	"strconv"
36	"strings"
37	"sync"
38	"syscall"
39	"time"
40
41	"github.com/mattn/go-colorable"
42	"github.com/mattn/go-isatty"
43)
44
45const (
46	testTimeout = time.Minute
47)
48
49func main() {
50	if err := run(); err != nil {
51		fmt.Println(err)
52		os.Exit(1)
53	}
54}
55
56func showUsage() {
57	fmt.Println(`
58run-cts is a tool used to run the WebGPU CTS using the Dawn module for NodeJS
59
60Usage:
61  run-cts --dawn-node=<path to dawn.node> --cts=<path to WebGPU CTS> [test-query]`)
62	os.Exit(1)
63}
64
65var (
66	colors  bool
67	stdout  io.Writer
68	mainCtx context.Context
69)
70
71type dawnNodeFlags []string
72
73func (f *dawnNodeFlags) String() string {
74	return fmt.Sprint(strings.Join(*f, ""))
75}
76
77func (f *dawnNodeFlags) Set(value string) error {
78	// Multiple flags must be passed in indivually:
79	// -flag=a=b -dawn_node_flag=c=d
80	*f = append(*f, value)
81	return nil
82}
83
84func makeMainCtx() context.Context {
85	ctx, cancel := context.WithCancel(context.Background())
86	sigs := make(chan os.Signal, 1)
87	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
88	go func() {
89		sig := <-sigs
90		fmt.Printf("Signal received: %v\n", sig)
91		cancel()
92	}()
93	return ctx
94}
95
96func run() error {
97	mainCtx = makeMainCtx()
98
99	colors = os.Getenv("TERM") != "dumb" ||
100		isatty.IsTerminal(os.Stdout.Fd()) ||
101		isatty.IsCygwinTerminal(os.Stdout.Fd())
102	if colors {
103		if _, disable := os.LookupEnv("NO_COLOR"); disable {
104			colors = false
105		}
106	}
107
108	backendDefault := "default"
109	if vkIcdFilenames := os.Getenv("VK_ICD_FILENAMES"); vkIcdFilenames != "" {
110		backendDefault = "vulkan"
111	}
112
113	var dawnNode, cts, node, npx, logFilename, backend string
114	var verbose, isolated, build bool
115	var numRunners int
116	var flags dawnNodeFlags
117	flag.StringVar(&dawnNode, "dawn-node", "", "path to dawn.node module")
118	flag.StringVar(&cts, "cts", "", "root directory of WebGPU CTS")
119	flag.StringVar(&node, "node", "", "path to node executable")
120	flag.StringVar(&npx, "npx", "", "path to npx executable")
121	flag.BoolVar(&verbose, "verbose", false, "print extra information while testing")
122	flag.BoolVar(&build, "build", true, "attempt to build the CTS before running")
123	flag.BoolVar(&isolated, "isolate", false, "run each test in an isolated process")
124	flag.BoolVar(&colors, "colors", colors, "enable / disable colors")
125	flag.IntVar(&numRunners, "j", runtime.NumCPU()/2, "number of concurrent runners. 0 runs serially")
126	flag.StringVar(&logFilename, "log", "", "path to log file of tests run and result")
127	flag.Var(&flags, "flag", "flag to pass to dawn-node as flag=value. multiple flags must be passed in individually")
128	flag.StringVar(&backend, "backend", backendDefault, "backend to use: default|null|webgpu|d3d11|d3d12|metal|vulkan|opengl|opengles."+
129		" set to 'vulkan' if VK_ICD_FILENAMES environment variable is set, 'default' otherwise")
130	flag.Parse()
131
132	if colors {
133		stdout = colorable.NewColorableStdout()
134	} else {
135		stdout = colorable.NewNonColorable(os.Stdout)
136	}
137
138	// Check mandatory arguments
139	if dawnNode == "" || cts == "" {
140		showUsage()
141	}
142	if !isFile(dawnNode) {
143		return fmt.Errorf("'%v' is not a file", dawnNode)
144	}
145	if !isDir(cts) {
146		return fmt.Errorf("'%v' is not a directory", cts)
147	}
148
149	// Make paths absolute
150	for _, path := range []*string{&dawnNode, &cts} {
151		abs, err := filepath.Abs(*path)
152		if err != nil {
153			return fmt.Errorf("unable to get absolute path for '%v'", *path)
154		}
155		*path = abs
156	}
157
158	// The test query is the optional unnamed argument
159	query := "webgpu:*"
160	switch len(flag.Args()) {
161	case 0:
162	case 1:
163		query = flag.Args()[0]
164	default:
165		return fmt.Errorf("only a single query can be provided")
166	}
167
168	// Find node
169	if node == "" {
170		var err error
171		node, err = exec.LookPath("node")
172		if err != nil {
173			return fmt.Errorf("add node to PATH or specify with --node")
174		}
175	}
176	// Find npx
177	if npx == "" {
178		var err error
179		npx, err = exec.LookPath("npx")
180		if err != nil {
181			npx = ""
182		}
183	}
184
185	if backend != "default" {
186		fmt.Println("Forcing backend to", backend)
187		flags = append(flags, fmt.Sprint("dawn-backend=", backend))
188	}
189
190	r := runner{
191		numRunners: numRunners,
192		verbose:    verbose,
193		node:       node,
194		npx:        npx,
195		dawnNode:   dawnNode,
196		cts:        cts,
197		flags:      flags,
198		evalScript: func(main string) string {
199			return fmt.Sprintf(`require('./src/common/tools/setup-ts-in-node.js');require('./src/common/runtime/%v.ts');`, main)
200		},
201	}
202
203	if logFilename != "" {
204		writer, err := os.Create(logFilename)
205		if err != nil {
206			return fmt.Errorf("failed to open log '%v': %w", logFilename, err)
207		}
208		defer writer.Close()
209		r.log = newLogger(writer)
210	}
211
212	cache := cache{}
213	cachePath := dawnNode + ".runcts.cache"
214	if err := cache.load(cachePath); err != nil && verbose {
215		fmt.Println("failed to load cache from", cachePath, err)
216	}
217	defer cache.save(cachePath)
218
219	// Scan the CTS source to determine the most recent change to the CTS source
220	mostRecentSourceChange, err := r.scanSourceTimestamps(verbose)
221	if err != nil {
222		return fmt.Errorf("failed to scan source files for modified timestamps: %w", err)
223	}
224
225	ctsNeedsRebuild := mostRecentSourceChange.After(cache.BuildTimestamp) ||
226		!isDir(filepath.Join(r.cts, "out-node"))
227	if build {
228		if verbose {
229			fmt.Println("CTS needs rebuild:", ctsNeedsRebuild)
230		}
231
232		if npx != "" {
233			if ctsNeedsRebuild {
234				if err := r.buildCTS(verbose); err != nil {
235					return fmt.Errorf("failed to build CTS: %w", err)
236				}
237				cache.BuildTimestamp = mostRecentSourceChange
238			}
239			// Use the prebuilt CTS (instead of using the `setup-ts-in-node` transpiler)
240			r.evalScript = func(main string) string {
241				return fmt.Sprintf(`require('./out-node/common/runtime/%v.js');`, main)
242			}
243		} else {
244			fmt.Println("npx not found on PATH. Using runtime TypeScript transpilation (slow)")
245		}
246	}
247
248	if numRunners > 0 {
249		// Find all the test cases that match the given queries.
250		if err := r.gatherTestCases(query, verbose); err != nil {
251			return fmt.Errorf("failed to gather test cases: %w", err)
252		}
253
254		if isolated {
255			fmt.Println("Running in parallel isolated...")
256			fmt.Printf("Testing %d test cases...\n", len(r.testcases))
257			return r.runParallelIsolated()
258		}
259		fmt.Println("Running in parallel with server...")
260		fmt.Printf("Testing %d test cases...\n", len(r.testcases))
261		return r.runParallelWithServer()
262	}
263
264	fmt.Println("Running serially...")
265	return r.runSerially(query)
266}
267
268type logger struct {
269	writer        io.Writer
270	idx           int
271	resultByIndex map[int]result
272}
273
274// newLogger creates a new logger instance.
275func newLogger(writer io.Writer) logger {
276	return logger{writer, 0, map[int]result{}}
277}
278
279// logResult writes the test results to the log file in sequential order.
280// logResult should be called whenever a new test result becomes available.
281func (l *logger) logResults(res result) {
282	if l.writer == nil {
283		return
284	}
285	l.resultByIndex[res.index] = res
286	for {
287		logRes, ok := l.resultByIndex[l.idx]
288		if !ok {
289			break
290		}
291		fmt.Fprintf(l.writer, "%v [%v]\n", logRes.testcase, logRes.status)
292		l.idx++
293	}
294}
295
296// Cache holds cached information between runs to optimize runs
297type cache struct {
298	BuildTimestamp time.Time
299}
300
301// load loads the cache information from the JSON file at path
302func (c *cache) load(path string) error {
303	f, err := os.Open(path)
304	if err != nil {
305		return err
306	}
307	defer f.Close()
308	return json.NewDecoder(f).Decode(c)
309}
310
311// save saves the cache information to the JSON file at path
312func (c *cache) save(path string) error {
313	f, err := os.Create(path)
314	if err != nil {
315		return err
316	}
317	defer f.Close()
318	return json.NewEncoder(f).Encode(c)
319}
320
321type runner struct {
322	numRunners               int
323	verbose                  bool
324	node, npx, dawnNode, cts string
325	flags                    dawnNodeFlags
326	evalScript               func(string) string
327	testcases                []string
328	log                      logger
329}
330
331// scanSourceTimestamps scans all the .js and .ts files in all subdirectories of
332// r.cts, and returns the file with the most recent timestamp.
333func (r *runner) scanSourceTimestamps(verbose bool) (time.Time, error) {
334	if verbose {
335		start := time.Now()
336		fmt.Println("Scanning .js / .ts files for changes...")
337		defer func() {
338			fmt.Println("completed in", time.Since(start))
339		}()
340	}
341
342	dir := filepath.Join(r.cts, "src")
343
344	mostRecentChange := time.Time{}
345	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
346		switch filepath.Ext(path) {
347		case ".ts", ".js":
348			if info.ModTime().After(mostRecentChange) {
349				mostRecentChange = info.ModTime()
350			}
351		}
352		return nil
353	})
354	if err != nil {
355		return time.Time{}, err
356	}
357	return mostRecentChange, nil
358}
359
360// buildCTS calls `npx grunt run:build-out-node` in the CTS directory to compile
361// the TypeScript files down to JavaScript. Doing this once ahead of time can be
362// much faster than dynamically transpiling when there are many tests to run.
363func (r *runner) buildCTS(verbose bool) error {
364	if verbose {
365		start := time.Now()
366		fmt.Println("Building CTS...")
367		defer func() {
368			fmt.Println("completed in", time.Since(start))
369		}()
370	}
371
372	cmd := exec.Command(r.npx, "grunt", "run:build-out-node")
373	cmd.Dir = r.cts
374	out, err := cmd.CombinedOutput()
375	if err != nil {
376		return fmt.Errorf("%w: %v", err, string(out))
377	}
378	return nil
379}
380
381// gatherTestCases() queries the CTS for all test cases that match the given
382// query. On success, gatherTestCases() populates r.testcases.
383func (r *runner) gatherTestCases(query string, verbose bool) error {
384	if verbose {
385		start := time.Now()
386		fmt.Println("Gathering test cases...")
387		defer func() {
388			fmt.Println("completed in", time.Since(start))
389		}()
390	}
391
392	args := append([]string{
393		"-e", r.evalScript("cmdline"),
394		"--", // Start of arguments
395		// src/common/runtime/helper/sys.ts expects 'node file.js <args>'
396		// and slices away the first two arguments. When running with '-e', args
397		// start at 1, so just inject a dummy argument.
398		"dummy-arg",
399		"--list",
400	}, query)
401
402	cmd := exec.Command(r.node, args...)
403	cmd.Dir = r.cts
404	out, err := cmd.CombinedOutput()
405	if err != nil {
406		return fmt.Errorf("%w\n%v", err, string(out))
407	}
408
409	tests := filterTestcases(strings.Split(string(out), "\n"))
410	r.testcases = tests
411	return nil
412}
413
414type portListener struct {
415	buffer strings.Builder
416	port   chan int
417}
418
419func newPortListener() portListener {
420	return portListener{strings.Builder{}, make(chan int)}
421}
422
423var portRE = regexp.MustCompile(`\[\[(\d+)\]\]`)
424
425func (p *portListener) Write(data []byte) (n int, err error) {
426	if p.port != nil {
427		p.buffer.Write(data)
428		match := portRE.FindStringSubmatch(p.buffer.String())
429		if len(match) == 2 {
430			port, err := strconv.Atoi(match[1])
431			if err != nil {
432				return 0, err
433			}
434			p.port <- port
435			close(p.port)
436			p.port = nil
437		}
438	}
439	return len(data), nil
440}
441
442// runParallelWithServer() starts r.numRunners instances of the CTS server test
443// runner, and issues test run requests to those servers, concurrently.
444func (r *runner) runParallelWithServer() error {
445	// Create a chan of test indices.
446	// This will be read by the test runner goroutines.
447	caseIndices := make(chan int, len(r.testcases))
448	for i := range r.testcases {
449		caseIndices <- i
450	}
451	close(caseIndices)
452
453	// Create a chan for the test results.
454	// This will be written to by the test runner goroutines.
455	results := make(chan result, len(r.testcases))
456
457	// Spin up the test runner goroutines
458	wg := &sync.WaitGroup{}
459	for i := 0; i < r.numRunners; i++ {
460		wg.Add(1)
461		go func() {
462			defer wg.Done()
463			if err := r.runServer(caseIndices, results); err != nil {
464				results <- result{
465					status: fail,
466					error:  fmt.Errorf("Test server error: %w", err),
467				}
468			}
469		}()
470	}
471
472	r.streamResults(wg, results)
473	return nil
474}
475
476type redirectingWriter struct {
477	io.Writer
478}
479
480// runServer starts a test runner server instance, takes case indices from
481// caseIndices, and requests the server run the test with the given index.
482// The result of the test run is written to the results chan.
483// Once the caseIndices chan has been closed, the server is stopped and
484// runServer returns.
485func (r *runner) runServer(caseIndices <-chan int, results chan<- result) error {
486	var port int
487	var rw redirectingWriter
488
489	stopServer := func() {}
490	startServer := func() error {
491		args := []string{
492			"-e", r.evalScript("server"), // Evaluate 'eval'.
493			"--",
494			// src/common/runtime/helper/sys.ts expects 'node file.js <args>'
495			// and slices away the first two arguments. When running with '-e', args
496			// start at 1, so just inject a dummy argument.
497			"dummy-arg",
498			// Actual arguments begin here
499			"--gpu-provider", r.dawnNode,
500		}
501		for _, f := range r.flags {
502			args = append(args, "--gpu-provider-flag", f)
503		}
504
505		ctx := mainCtx
506		cmd := exec.CommandContext(ctx, r.node, args...)
507
508		serverLog := &bytes.Buffer{}
509
510		pl := newPortListener()
511
512		cmd.Dir = r.cts
513		cmd.Stdout = io.MultiWriter(&rw, serverLog, &pl)
514		cmd.Stderr = io.MultiWriter(&rw, serverLog)
515
516		err := cmd.Start()
517		if err != nil {
518			return fmt.Errorf("failed to start test runner server: %v", err)
519		}
520
521		select {
522		case port = <-pl.port:
523		case <-time.After(time.Second * 10):
524			return fmt.Errorf("timeout waiting for server port:\n%v", serverLog.String())
525		case <-ctx.Done():
526			return ctx.Err()
527		}
528
529		return nil
530	}
531	stopServer = func() {
532		if port > 0 {
533			go http.Post(fmt.Sprintf("http://localhost:%v/terminate", port), "", &bytes.Buffer{})
534			time.Sleep(time.Millisecond * 100)
535			port = 0
536		}
537	}
538
539	for idx := range caseIndices {
540		// Redirect the server log per test case
541		caseServerLog := &bytes.Buffer{}
542		rw.Writer = caseServerLog
543
544		if port == 0 {
545			if err := startServer(); err != nil {
546				return err
547			}
548		}
549
550		res := result{index: idx, testcase: r.testcases[idx]}
551
552		type Response struct {
553			Status  string
554			Message string
555		}
556		postResp, err := http.Post(fmt.Sprintf("http://localhost:%v/run?%v", port, r.testcases[idx]), "", &bytes.Buffer{})
557		if err != nil {
558			res.error = fmt.Errorf("server POST failure. Restarting server...")
559			res.status = fail
560			results <- res
561			stopServer()
562			continue
563		}
564
565		if postResp.StatusCode == http.StatusOK {
566			var resp Response
567			if err := json.NewDecoder(postResp.Body).Decode(&resp); err != nil {
568				res.error = fmt.Errorf("server response decode failure")
569				res.status = fail
570				results <- res
571				continue
572			}
573
574			switch resp.Status {
575			case "pass":
576				res.status = pass
577				res.message = resp.Message + caseServerLog.String()
578			case "warn":
579				res.status = warn
580				res.message = resp.Message + caseServerLog.String()
581			case "fail":
582				res.status = fail
583				res.message = resp.Message + caseServerLog.String()
584			case "skip":
585				res.status = skip
586				res.message = resp.Message + caseServerLog.String()
587			default:
588				res.status = fail
589				res.error = fmt.Errorf("unknown status: '%v'", resp.Status)
590			}
591		} else {
592			msg, err := ioutil.ReadAll(postResp.Body)
593			if err != nil {
594				msg = []byte(err.Error())
595			}
596			res.status = fail
597			res.error = fmt.Errorf("server error: %v", string(msg))
598		}
599		results <- res
600	}
601
602	stopServer()
603	return nil
604}
605
606// runParallelIsolated() calls the CTS command-line test runner to run each
607// testcase in a separate process. This reduces possibility of state leakage
608// between tests.
609// Up to r.numRunners tests will be run concurrently.
610func (r *runner) runParallelIsolated() error {
611	// Create a chan of test indices.
612	// This will be read by the test runner goroutines.
613	caseIndices := make(chan int, len(r.testcases))
614	for i := range r.testcases {
615		caseIndices <- i
616	}
617	close(caseIndices)
618
619	// Create a chan for the test results.
620	// This will be written to by the test runner goroutines.
621	results := make(chan result, len(r.testcases))
622
623	// Spin up the test runner goroutines
624	wg := &sync.WaitGroup{}
625	for i := 0; i < r.numRunners; i++ {
626		wg.Add(1)
627		go func() {
628			defer wg.Done()
629			for idx := range caseIndices {
630				res := r.runTestcase(r.testcases[idx])
631				res.index = idx
632				results <- res
633			}
634		}()
635	}
636
637	r.streamResults(wg, results)
638	return nil
639}
640
641// streamResults reads from the chan 'results', printing the results in test-id
642// sequential order. Once the WaitGroup 'wg' is complete, streamResults() will
643// automatically close the 'results' chan.
644// Once all the results have been printed, a summary will be printed and the
645// function will return.
646func (r *runner) streamResults(wg *sync.WaitGroup, results chan result) {
647	// Create another goroutine to close the results chan when all the runner
648	// goroutines have finished.
649	start := time.Now()
650	var timeTaken time.Duration
651	go func() {
652		wg.Wait()
653		timeTaken = time.Since(start)
654		close(results)
655	}()
656
657	// Total number of tests, test counts binned by status
658	numTests, numByStatus := len(r.testcases), map[status]int{}
659
660	// Helper function for printing a progress bar.
661	lastStatusUpdate, animFrame := time.Now(), 0
662	updateProgress := func() {
663		printANSIProgressBar(animFrame, numTests, numByStatus)
664		animFrame++
665		lastStatusUpdate = time.Now()
666	}
667
668	// Pull test results as they become available.
669	// Update the status counts, and print any failures (or all test results if --verbose)
670	progressUpdateRate := time.Millisecond * 10
671	if !colors {
672		// No colors == no cursor control. Reduce progress updates so that
673		// we're not printing endless progress bars.
674		progressUpdateRate = time.Second
675	}
676
677	for res := range results {
678		r.log.logResults(res)
679
680		numByStatus[res.status] = numByStatus[res.status] + 1
681		name := res.testcase
682		if r.verbose || res.error != nil || (res.status != pass && res.status != skip) {
683			fmt.Printf("%v - %v: %v\n", name, res.status, res.message)
684			if res.error != nil {
685				fmt.Println(res.error)
686			}
687			updateProgress()
688		}
689		if time.Since(lastStatusUpdate) > progressUpdateRate {
690			updateProgress()
691		}
692	}
693	printANSIProgressBar(animFrame, numTests, numByStatus)
694
695	// All done. Print final stats.
696	fmt.Printf(`
697Completed in %v
698
699pass:    %v (%v)
700fail:    %v (%v)
701skip:    %v (%v)
702timeout: %v (%v)
703`,
704		timeTaken,
705		numByStatus[pass], percentage(numByStatus[pass], numTests),
706		numByStatus[fail], percentage(numByStatus[fail], numTests),
707		numByStatus[skip], percentage(numByStatus[skip], numTests),
708		numByStatus[timeout], percentage(numByStatus[timeout], numTests),
709	)
710}
711
712// runSerially() calls the CTS test runner to run the test query in a single
713// process.
714func (r *runner) runSerially(query string) error {
715	start := time.Now()
716	result := r.runTestcase(query)
717	timeTaken := time.Since(start)
718
719	if r.verbose {
720		fmt.Println(result)
721	}
722	fmt.Println("Status:", result.status)
723	fmt.Println("Completed in", timeTaken)
724	return nil
725}
726
727// status is an enumerator of test result status
728type status string
729
730const (
731	pass    status = "pass"
732	warn    status = "warn"
733	fail    status = "fail"
734	skip    status = "skip"
735	timeout status = "timeout"
736)
737
738// result holds the information about a completed test
739type result struct {
740	index    int
741	testcase string
742	status   status
743	message  string
744	error    error
745}
746
747// runTestcase() runs the CTS testcase with the given query, returning the test
748// result.
749func (r *runner) runTestcase(query string) result {
750	ctx, cancel := context.WithTimeout(mainCtx, testTimeout)
751	defer cancel()
752
753	args := []string{
754		"-e", r.evalScript("cmdline"), // Evaluate 'eval'.
755		"--",
756		// src/common/runtime/helper/sys.ts expects 'node file.js <args>'
757		// and slices away the first two arguments. When running with '-e', args
758		// start at 1, so just inject a dummy argument.
759		"dummy-arg",
760		// Actual arguments begin here
761		"--gpu-provider", r.dawnNode,
762		"--verbose",
763	}
764	for _, f := range r.flags {
765		args = append(args, "--gpu-provider-flag", f)
766	}
767	args = append(args, query)
768
769	cmd := exec.CommandContext(ctx, r.node, args...)
770	cmd.Dir = r.cts
771
772	var buf bytes.Buffer
773	cmd.Stdout = &buf
774	cmd.Stderr = &buf
775
776	err := cmd.Run()
777	msg := buf.String()
778	switch {
779	case errors.Is(err, context.DeadlineExceeded):
780		return result{testcase: query, status: timeout, message: msg}
781	case strings.Contains(msg, "[fail]"):
782		return result{testcase: query, status: fail, message: msg}
783	case strings.Contains(msg, "[warn]"):
784		return result{testcase: query, status: warn, message: msg}
785	case strings.Contains(msg, "[skip]"):
786		return result{testcase: query, status: skip, message: msg}
787	case strings.Contains(msg, "[pass]"), err == nil:
788		return result{testcase: query, status: pass, message: msg}
789	}
790	return result{testcase: query, status: fail, message: fmt.Sprint(msg, err), error: err}
791}
792
793// filterTestcases returns in with empty strings removed
794func filterTestcases(in []string) []string {
795	out := make([]string, 0, len(in))
796	for _, c := range in {
797		if c != "" {
798			out = append(out, c)
799		}
800	}
801	return out
802}
803
804// percentage returns the percentage of n out of total as a string
805func percentage(n, total int) string {
806	if total == 0 {
807		return "-"
808	}
809	f := float64(n) / float64(total)
810	return fmt.Sprintf("%.1f%c", f*100.0, '%')
811}
812
813// isDir returns true if the path resolves to a directory
814func isDir(path string) bool {
815	s, err := os.Stat(path)
816	if err != nil {
817		return false
818	}
819	return s.IsDir()
820}
821
822// isFile returns true if the path resolves to a file
823func isFile(path string) bool {
824	s, err := os.Stat(path)
825	if err != nil {
826		return false
827	}
828	return !s.IsDir()
829}
830
831// printANSIProgressBar prints a colored progress bar, providing realtime
832// information about the status of the CTS run.
833// Note: We'll want to skip this if !isatty or if we're running on windows.
834func printANSIProgressBar(animFrame int, numTests int, numByStatus map[status]int) {
835	const (
836		barWidth = 50
837
838		escape       = "\u001B["
839		positionLeft = escape + "0G"
840		red          = escape + "31m"
841		green        = escape + "32m"
842		yellow       = escape + "33m"
843		blue         = escape + "34m"
844		magenta      = escape + "35m"
845		cyan         = escape + "36m"
846		white        = escape + "37m"
847		reset        = escape + "0m"
848	)
849
850	animSymbols := []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'}
851	blockSymbols := []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉'}
852
853	numBlocksPrinted := 0
854
855	fmt.Fprint(stdout, string(animSymbols[animFrame%len(animSymbols)]), " [")
856	animFrame++
857
858	numFinished := 0
859
860	for _, ty := range []struct {
861		status status
862		color  string
863	}{{pass, green}, {warn, yellow}, {skip, blue}, {timeout, yellow}, {fail, red}} {
864		num := numByStatus[ty.status]
865		numFinished += num
866		statusFrac := float64(num) / float64(numTests)
867		fNumBlocks := barWidth * statusFrac
868		fmt.Fprint(stdout, ty.color)
869		numBlocks := int(math.Ceil(fNumBlocks))
870		if numBlocks > 1 {
871			fmt.Print(strings.Repeat(string("▉"), numBlocks))
872		}
873		if numBlocks > 0 {
874			frac := fNumBlocks - math.Floor(fNumBlocks)
875			symbol := blockSymbols[int(math.Round(frac*float64(len(blockSymbols)-1)))]
876			fmt.Print(string(symbol))
877		}
878		numBlocksPrinted += numBlocks
879	}
880
881	if barWidth > numBlocksPrinted {
882		fmt.Print(strings.Repeat(string(" "), barWidth-numBlocksPrinted))
883	}
884	fmt.Fprint(stdout, reset)
885	fmt.Print("] ", percentage(numFinished, numTests))
886
887	if colors {
888		// move cursor to start of line so the bar is overridden
889		fmt.Fprint(stdout, positionLeft)
890	} else {
891		// cannot move cursor, so newline
892		fmt.Println()
893	}
894}
895