[tools] Replace test-all.sh with ./tools/run tests

• Simplify the usage by allowing globs, directories or single test file paths as unnamed arguments. Removes the `--filter` flag that nobody really understood.
• Make the table fit the terminal.
• Replace dawn-path with `<dawn>` in expectation files.

Change-Id: I45a0579e3589888664877c22edb575d1a4ed18c1
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/161701
Reviewed-by: dan sinclair <dsinclair@chromium.org>
Kokoro: Kokoro <noreply+kokoro@google.com>
Commit-Queue: Ben Clayton <bclayton@google.com>
diff --git a/tools/src/cmd/test-runner/main.go b/tools/src/cmd/tests/main.go
similarity index 83%
rename from tools/src/cmd/test-runner/main.go
rename to tools/src/cmd/tests/main.go
index 791ea03..9866c9c 100644
--- a/tools/src/cmd/test-runner/main.go
+++ b/tools/src/cmd/tests/main.go
@@ -25,7 +25,7 @@
 // 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.
 
-// test-runner runs tint against a number of test shaders checking for expected behavior
+// tests runs tint against a number of test shaders checking for expected behavior
 package main
 
 import (
@@ -37,6 +37,7 @@
 	"io/ioutil"
 	"os"
 	"os/exec"
+	"path"
 	"path/filepath"
 	"regexp"
 	"runtime"
@@ -47,8 +48,11 @@
 
 	"dawn.googlesource.com/dawn/tools/src/fileutils"
 	"dawn.googlesource.com/dawn/tools/src/glob"
+	"dawn.googlesource.com/dawn/tools/src/match"
+	"dawn.googlesource.com/dawn/tools/src/transform"
 	"github.com/fatih/color"
 	"github.com/sergi/go-diff/diffmatchpatch"
+	"golang.org/x/term"
 )
 
 type outputFormat string
@@ -64,14 +68,27 @@
 	wgsl    = outputFormat("wgsl")
 )
 
+// The root directory of the dawn project
+var dawnRoot = fileutils.DawnRoot()
+
+// The default non-flag arguments to the command
+var defaultArgs = []string{"test/tint"}
+
+// The globs automatically appended if a glob argument is a directory
+var directoryGlobs = []string{
+	"**.wgsl",
+	"**.spvasm",
+	"**.spv",
+}
+
 // Directories we don't generate expected PASS result files for.
 // These directories contain large corpora of tests for which the generated code
 // is uninteresting.
 // These paths use unix-style slashes and do not contain the '/test/tint' prefix.
 var dirsWithNoPassExpectations = []string{
-	"benchmark/",
-	"unittest/",
-	"vk-gl-cts/",
+	dawnRoot + "/test/tint/benchmark/",
+	dawnRoot + "/test/tint/unittest/",
+	dawnRoot + "/test/tint/vk-gl-cts/",
 }
 
 func main() {
@@ -82,98 +99,116 @@
 }
 
 func showUsage() {
-	fmt.Println(`
-test-runner runs tint against a number of test shaders checking for expected behavior
+	fmt.Printf(`
+tests runs tint against a number of test shaders checking for expected behavior
 
 usage:
-  test-runner [flags...] <executable> [<directory>]
+  tests [flags...] [globs...]
 
-  <executable> the path to the tint executable
-  <directory>  the root directory of the test files
+  [globs]  a list of project-root relative file globs, directory or file paths
+           of test cases.
+           A file path will be added to the test list.
+           A directory will automatically expand to the globs:
+                %v
+           Globs will include all test files that match the glob, but exclude
+		   those that match the --ignore flag.
+           If omitted, defaults to: %v
 
-optional flags:`)
+optional flags:`,
+		transform.SliceNoErr(directoryGlobs, func(in string) string { return fmt.Sprintf("'<dir>/%v'", in) }),
+		transform.SliceNoErr(defaultArgs, func(in string) string { return fmt.Sprintf("'%v'", in) }))
 	flag.PrintDefaults()
 	fmt.Println(``)
 	os.Exit(1)
 }
 
 func run() error {
-	var formatList, filter, dxcPath, fxcPath, xcrunPath string
-	var maxFilenameColumnWidth int
+	terminalWidth, _, err := term.GetSize(int(os.Stdout.Fd()))
+	if err != nil {
+		terminalWidth = 0
+	}
+
+	var formatList, ignore, dxcPath, fxcPath, tintPath, xcrunPath string
+	var maxTableWidth int
 	numCPU := runtime.NumCPU()
 	verbose, useIr, generateExpected, generateSkip := false, false, false, false
 	flag.StringVar(&formatList, "format", "all", "comma separated list of formats to emit. Possible values are: all, wgsl, spvasm, msl, hlsl, hlsl-dxc, hlsl-fxc, glsl")
-	flag.StringVar(&filter, "filter", "**.wgsl, **.spvasm, **.spv", "comma separated list of glob patterns for test files")
+	flag.StringVar(&ignore, "ignore", "**.expected.*", "files to ignore in globs")
 	flag.StringVar(&dxcPath, "dxc", "", "path to DXC executable for validating HLSL output")
 	flag.StringVar(&fxcPath, "fxc", "", "path to FXC DLL for validating HLSL output")
+	flag.StringVar(&tintPath, "tint", defaultTintPath(), "path to the tint executable")
 	flag.StringVar(&xcrunPath, "xcrun", "", "path to xcrun executable for validating MSL output")
 	flag.BoolVar(&verbose, "verbose", false, "print all run tests, including rows that all pass")
 	flag.BoolVar(&useIr, "use-ir", false, "generate with the IR enabled")
 	flag.BoolVar(&generateExpected, "generate-expected", false, "create or update all expected outputs")
 	flag.BoolVar(&generateSkip, "generate-skip", false, "create or update all expected outputs that fail with SKIP")
 	flag.IntVar(&numCPU, "j", numCPU, "maximum number of concurrent threads to run tests")
-	flag.IntVar(&maxFilenameColumnWidth, "filename-column-width", 0, "maximum width of the filename column")
+	flag.IntVar(&maxTableWidth, "table-width", terminalWidth, "maximum width of the results table")
 	flag.Usage = showUsage
 	flag.Parse()
 
-	args := flag.Args()
-	if len(args) == 0 {
+	// Check the executable can be found and actually is executable
+	if !fileutils.IsExe(tintPath) {
+		fmt.Fprintln(os.Stderr, "tint executable not found, please specify with --tint")
 		showUsage()
 	}
 
-	// executable path is the first argument
-	exe, args := args[0], args[1:]
-
-	// (optional) target directory is the second argument
-	dir := "."
-	if len(args) > 0 {
-		dir, args = args[0], args[1:]
+	// Apply default args, if not provided
+	args := flag.Args()
+	if len(args) == 0 {
+		args = defaultArgs
 	}
 
-	// Check the executable can be found and actually is executable
-	if !fileutils.IsExe(exe) {
-		return fmt.Errorf("'%s' not found or is not executable", exe)
-	}
-	exe, err := filepath.Abs(exe)
-	if err != nil {
-		return err
+	filePredicate := func(s string) bool { return true }
+	if m, err := match.New(ignore); err == nil {
+		filePredicate = func(s string) bool { return !m(s) }
+	} else {
+		return fmt.Errorf("failed to parse --ignore: %w", err)
 	}
 
-	// Allow using '/' in the filter on Windows
-	filter = strings.ReplaceAll(filter, "/", string(filepath.Separator))
+	// Transform args to globs, find the rootPath directory
+	absFiles := []string{}
+	rootPath := ""
+	globs := []string{}
+	for _, arg := range args {
+		// Make absolute
+		if !filepath.IsAbs(arg) {
+			arg = filepath.Join(dawnRoot, arg)
+		}
 
-	// Split the --filter flag up by ',', trimming any whitespace at the start and end
-	globIncludes := strings.Split(filter, ",")
-	for i, s := range globIncludes {
-		s = filepath.ToSlash(s) // Replace '\' with '/'
-		globIncludes[i] = `"` + strings.TrimSpace(s) + `"`
-	}
-
-	// Glob the files to test
-	files, err := glob.Scan(dir, glob.MustParseConfig(`{
-		"paths": [
-			{
-				"include": [ `+strings.Join(globIncludes, ",")+` ]
-			},
-			{
-				"exclude": [
-					"**.expected.wgsl",
-					"**.expected.spvasm",
-					"**.expected.ir.spvasm",
-					"**.expected.msl",
-					"**.expected.fxc.hlsl",
-					"**.expected.dxc.hlsl",
-					"**.expected.glsl"
-				]
+		switch {
+		case fileutils.IsDir(arg):
+			// Argument is to a directory, expand out to N globs
+			for _, glob := range directoryGlobs {
+				globs = append(globs, path.Join(arg, glob))
 			}
-		]
-	}`))
-	if err != nil {
-		return fmt.Errorf("Failed to glob files: %w", err)
+		case fileutils.IsFile(arg):
+			// Argument is a file, append to absFiles
+			absFiles = append(absFiles, arg)
+		default:
+			globs = append(globs, arg)
+		}
+
+		if rootPath == "" {
+			rootPath = filepath.Dir(arg)
+		} else {
+			rootPath = fileutils.CommonRootDir(rootPath, arg)
+		}
+	}
+
+	// Glob the absFiles to test
+	for _, g := range globs {
+		globFiles, err := glob.Glob(g)
+		if err != nil {
+			return fmt.Errorf("Failed to glob files: %w", err)
+		}
+		filtered := transform.Filter(globFiles, filePredicate)
+		normalized := transform.SliceNoErr(filtered, filepath.ToSlash)
+		absFiles = append(absFiles, normalized...)
 	}
 
 	// Ensure the files are sorted (globbing should do this, but why not)
-	sort.Strings(files)
+	sort.Strings(absFiles)
 
 	// Parse --format into a list of outputFormat
 	formats := []outputFormat{}
@@ -255,8 +290,8 @@
 
 	// Build the list of results.
 	// These hold the chans used to report the job results.
-	results := make([]map[outputFormat]chan status, len(files))
-	for i := range files {
+	results := make([]map[outputFormat]chan status, len(absFiles))
+	for i := range absFiles {
 		fileResults := map[outputFormat]chan status{}
 		for _, format := range formats {
 			fileResults[format] = make(chan status, 1)
@@ -268,8 +303,8 @@
 
 	// Spawn numCPU job runners...
 	runCfg := runConfig{
-		wd:               dir,
-		exe:              exe,
+		rootPath:         rootPath,
+		tintPath:         tintPath,
 		dxcPath:          dxcPath,
 		fxcPath:          fxcPath,
 		xcrunPath:        xcrunPath,
@@ -288,8 +323,7 @@
 
 	// Issue the jobs...
 	go func() {
-		for i, file := range files { // For each test file...
-			file := filepath.Join(dir, file)
+		for i, file := range absFiles { // For each test file...
 			flags := parseFlags(file)
 			for _, format := range formats { // For each output format...
 				pendingJobs <- job{
@@ -320,11 +354,31 @@
 		statsByFmt[format] = &stats{}
 	}
 
+	// Make file paths relative to rootPath, if possible
+	relFiles := transform.GoSliceNoErr(absFiles, func(path string) string {
+		path = filepath.ToSlash(path) // Normalize
+		if rel, err := filepath.Rel(rootPath, path); err == nil {
+			return rel
+		}
+		return path
+	})
+
 	// Print the table of file x format and gather per-format stats
 	failures := []failure{}
-	filenameColumnWidth := maxStringLen(files)
-	if maxFilenameColumnWidth > 0 {
-		filenameColumnWidth = maxFilenameColumnWidth
+	filenameColumnWidth := maxStringLen(relFiles)
+
+	// Calculate the table width
+	tableWidth := filenameColumnWidth + 3
+	for _, format := range formats {
+		tableWidth += formatWidth(format) + 3
+	}
+
+	// Reduce filename column width if too big
+	if tableWidth > maxTableWidth {
+		filenameColumnWidth -= tableWidth - maxTableWidth
+		if filenameColumnWidth < 20 {
+			filenameColumnWidth = 20
+		}
 	}
 
 	red := color.New(color.FgRed)
@@ -358,7 +412,7 @@
 
 	newKnownGood := knownGoodHashes{}
 
-	for i, file := range files {
+	for i, file := range relFiles {
 		results := results[i]
 
 		row := &strings.Builder{}
@@ -560,8 +614,8 @@
 }
 
 type runConfig struct {
-	wd               string
-	exe              string
+	rootPath         string
+	tintPath         string
 	dxcPath          string
 	fxcPath          string
 	xcrunPath        string
@@ -591,7 +645,7 @@
 
 		// Is there an expected output file? If so, load it.
 		expected, expectedFileExists := "", false
-		if content, err := ioutil.ReadFile(expectedFilePath); err == nil {
+		if content, err := os.ReadFile(expectedFilePath); err == nil {
 			expected = string(content)
 			expectedFileExists = true
 		}
@@ -603,17 +657,8 @@
 
 		expected = strings.ReplaceAll(expected, "\r\n", "\n")
 
-		file, err := filepath.Rel(cfg.wd, j.file)
-		if err != nil {
-			file = j.file
-		}
-
-		// Make relative paths use forward slash separators (on Windows) so that paths in tint
-		// output match expected output that contain errors
-		file = strings.ReplaceAll(file, `\`, `/`)
-
 		args := []string{
-			file,
+			j.file,
 			"--format", strings.Split(string(j.format), "-")[0], // 'hlsl-fxc' -> 'hlsl', etc.
 			"--print-hash",
 		}
@@ -624,7 +669,7 @@
 
 		// Append any skip-hashes, if they're found.
 		if j.format != "wgsl" { // Don't skip 'wgsl' as this 'toolchain' is ever changing.
-			if skipHashes := cfg.validationCache.knownGood[fileAndFormat{file, j.format}]; len(skipHashes) > 0 {
+			if skipHashes := cfg.validationCache.knownGood[fileAndFormat{j.file, j.format}]; len(skipHashes) > 0 {
 				args = append(args, "--skip-hash", strings.Join(skipHashes, ","))
 			}
 		}
@@ -663,23 +708,24 @@
 		args = append(args, j.flags...)
 
 		start := time.Now()
-		ok, out = invoke(cfg.wd, cfg.exe, args...)
+		ok, out = invoke(cfg.rootPath, cfg.tintPath, args...)
 		timeTaken := time.Since(start)
 
 		out = strings.ReplaceAll(out, "\r\n", "\n")
+		out = strings.ReplaceAll(out, filepath.ToSlash(dawnRoot), "<dawn>")
 		out, hashes := extractValidationHashes(out)
 		matched := expected == "" || expected == out
 
 		canEmitPassExpectationFile := true
 		for _, noPass := range dirsWithNoPassExpectations {
-			if strings.HasPrefix(file, noPass) {
+			if strings.HasPrefix(j.file, noPass) {
 				canEmitPassExpectationFile = false
 				break
 			}
 		}
 
 		saveExpectedFile := func(path string, content string) error {
-			return ioutil.WriteFile(path, []byte(content), 0666)
+			return os.WriteFile(path, []byte(content), 0666)
 		}
 
 		if ok && cfg.generateExpected && (validate || !skipped) {
@@ -998,3 +1044,13 @@
 	enc.SetIndent("", "  ")
 	return enc.Encode(&out)
 }
+
+// defaultRootPath returns the default path to the root of the test tree
+func defaultRootPath() string {
+	return filepath.Join(fileutils.DawnRoot(), "test/tint")
+}
+
+// defaultTintPath returns the default path to the tint executable
+func defaultTintPath() string {
+	return filepath.Join(fileutils.DawnRoot(), "out/active/tint")
+}
diff --git a/tools/src/fileutils/paths.go b/tools/src/fileutils/paths.go
index e6f03f8..a2edcfb 100644
--- a/tools/src/fileutils/paths.go
+++ b/tools/src/fileutils/paths.go
@@ -137,3 +137,29 @@
 	}
 	return !s.IsDir()
 }
+
+// CommonRootDir returns the common directory for pathA and pathB
+func CommonRootDir(pathA, pathB string) string {
+	pathA, pathB = filepath.ToSlash(pathA), filepath.ToSlash(pathB) // Normalize to forward-slash
+	if !strings.HasSuffix(pathA, "/") {
+		pathA += "/"
+	}
+	if !strings.HasSuffix(pathB, "/") {
+		pathB += "/"
+	}
+	n := len(pathA)
+	if len(pathB) < n {
+		n = len(pathB)
+	}
+	common := ""
+	for i := 0; i < n; i++ {
+		a, b := pathA[i], pathB[i]
+		if a != b {
+			break
+		}
+		if a == '/' {
+			common = pathA[:i+1]
+		}
+	}
+	return common
+}
diff --git a/tools/src/fileutils/paths_test.go b/tools/src/fileutils/paths_test.go
index 46102b9..ec0f1d3 100644
--- a/tools/src/fileutils/paths_test.go
+++ b/tools/src/fileutils/paths_test.go
@@ -62,3 +62,26 @@
 		t.Errorf("DawnRoot() returned %v.\n%v", dr, diff)
 	}
 }
+
+func TestCommonRootDir(t *testing.T) {
+	for _, test := range []struct {
+		a, b   string
+		expect string
+	}{
+		{"", "", "/"},
+		{"a/b/c", "d/e/f", ""},
+		{"a/", "b", ""},
+		{"a/b/c", "a/b", "a/b/"},
+		{"a/b/c/", "a/b", "a/b/"},
+		{"a/b/c/", "a/b/", "a/b/"},
+		{"a/b/c", "a/b/d", "a/b/"},
+		{"a/b/c", "a/bc", "a/"},
+	} {
+		if got := fileutils.CommonRootDir(test.a, test.b); got != test.expect {
+			t.Errorf("CommonRootDir('%v', '%v') returned '%v'.\nExpected: '%v'", test.a, test.b, got, test.expect)
+		}
+		if got := fileutils.CommonRootDir(test.b, test.a); got != test.expect {
+			t.Errorf("CommonRootDir('%v', '%v') returned '%v'.\nExpected: '%v'", test.b, test.a, got, test.expect)
+		}
+	}
+}
diff --git a/tools/src/glob/glob.go b/tools/src/glob/glob.go
index 0e05306..94648d3 100644
--- a/tools/src/glob/glob.go
+++ b/tools/src/glob/glob.go
@@ -71,7 +71,7 @@
 		}
 	}
 	// No wildcard found. Does the file exist at 'str'?
-	if s, err := os.Stat(str); err != nil && !s.IsDir() {
+	if s, err := os.Stat(str); err == nil && !s.IsDir() {
 		return []string{str}, nil
 	}
 	return []string{}, nil
diff --git a/tools/src/transform/slice.go b/tools/src/transform/slice.go
index de118bc..1f965a0 100644
--- a/tools/src/transform/slice.go
+++ b/tools/src/transform/slice.go
@@ -66,6 +66,15 @@
 	return out, nil
 }
 
+// SliceNoErr returns a new slice by transforming each element with the function fn
+func SliceNoErr[IN any, OUT any](in []IN, fn func(in IN) OUT) []OUT {
+	out := make([]OUT, len(in))
+	for i, el := range in {
+		out[i] = fn(el)
+	}
+	return out
+}
+
 // GoSlice returns a new slice by transforming each element with the function
 // fn, called by multiple go-routines.
 func GoSlice[IN any, OUT any](in []IN, fn func(in IN) (OUT, error)) ([]OUT, error) {
@@ -102,3 +111,35 @@
 
 	return out, nil
 }
+
+// GoSliceNoErr returns a new slice by transforming each element with the function
+// fn, called by multiple go-routines.
+func GoSliceNoErr[IN any, OUT any](in []IN, fn func(in IN) OUT) []OUT {
+
+	// Create a channel of indices
+	indices := make(chan int, 256)
+	go func() {
+		for i := range in {
+			indices <- i
+		}
+		close(indices)
+	}()
+
+	out := make([]OUT, len(in))
+
+	// Kick a number of workers to process the elements
+	numWorkers := runtime.NumCPU()
+	wg := sync.WaitGroup{}
+	wg.Add(numWorkers)
+	for worker := 0; worker < numWorkers; worker++ {
+		go func() {
+			defer wg.Done()
+			for idx := range indices {
+				out[idx] = fn(in[idx])
+			}
+		}()
+	}
+	wg.Wait()
+
+	return out
+}