blob: 1b106befca98dc1eae55a8de1ad8a18a6d60daf6 [file] [log] [blame]
// Copyright 2021 The 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.
// test-runner runs tint against a number of test shaders checking for expected behavior
package main
import (
"flag"
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"unicode/utf8"
"dawn.googlesource.com/tint/tools/src/fileutils"
"dawn.googlesource.com/tint/tools/src/glob"
"github.com/fatih/color"
"github.com/sergi/go-diff/diffmatchpatch"
)
type outputFormat string
const (
wgsl = outputFormat("wgsl")
spvasm = outputFormat("spvasm")
msl = outputFormat("msl")
hlsl = outputFormat("hlsl")
)
func main() {
if err := run(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func showUsage() {
fmt.Println(`
test-runner runs tint against a number of test shaders checking for expected behavior
usage:
test-runner [flags...] <executable> [<directory>]
<executable> the path to the tint executable
<directory> the root directory of the test files
optional flags:`)
flag.PrintDefaults()
fmt.Println(``)
os.Exit(1)
}
func run() error {
var formatList, filter, dxcPath, xcrunPath string
numCPU := runtime.NumCPU()
generateExpected, generateSkip := false, false
flag.StringVar(&formatList, "format", "all", "comma separated list of formats to emit. Possible values are: all, wgsl, spvasm, msl, hlsl")
flag.StringVar(&filter, "filter", "**.wgsl, **.spvasm, **.spv", "comma separated list of glob patterns for test files")
flag.StringVar(&dxcPath, "dxc", "", "path to DXC executable for validating HLSL output")
flag.StringVar(&xcrunPath, "xcrun", "", "path to xcrun executable for validating MSL output")
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.Usage = showUsage
flag.Parse()
args := flag.Args()
if len(args) == 0 {
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:]
}
// 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
}
// Allow using '/' in the filter on Windows
filter = strings.ReplaceAll(filter, "/", string(filepath.Separator))
// Split the --filter flag up by ',', trimming any whitespace at the start and end
globIncludes := strings.Split(filter, ",")
for i, s := range globIncludes {
// Escape backslashes for the glob config
s = strings.ReplaceAll(s, `\`, `\\`)
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.msl",
"**.expected.hlsl"
]
}
]
}`))
if err != nil {
return fmt.Errorf("Failed to glob files: %w", err)
}
// Ensure the files are sorted (globbing should do this, but why not)
sort.Strings(files)
// Parse --format into a list of outputFormat
formats := []outputFormat{}
if formatList == "all" {
formats = []outputFormat{wgsl, spvasm, msl, hlsl}
} else {
for _, f := range strings.Split(formatList, ",") {
switch strings.TrimSpace(f) {
case "wgsl":
formats = append(formats, wgsl)
case "spvasm":
formats = append(formats, spvasm)
case "msl":
formats = append(formats, msl)
case "hlsl":
formats = append(formats, hlsl)
default:
return fmt.Errorf("unknown format '%s'", f)
}
}
}
default_msl_exe := "xcrun"
if runtime.GOOS == "windows" {
default_msl_exe = "metal.exe"
}
// If explicit verification compilers have been specified, check they exist.
// Otherwise, look on PATH for them, but don't error if they cannot be found.
for _, tool := range []struct {
name string
lang string
path *string
}{
{"dxc", "hlsl", &dxcPath},
{default_msl_exe, "msl", &xcrunPath},
} {
if *tool.path == "" {
p, err := exec.LookPath(tool.name)
if err == nil && fileutils.IsExe(p) {
*tool.path = p
}
} else if !fileutils.IsExe(*tool.path) {
return fmt.Errorf("%v not found at '%v'", tool.name, *tool.path)
}
color.Set(color.FgCyan)
fmt.Printf("%-4s", tool.lang)
color.Unset()
fmt.Printf(" validation ")
if *tool.path == "" {
color.Set(color.FgRed)
fmt.Printf("DISABLED")
} else {
color.Set(color.FgGreen)
fmt.Printf("ENABLED")
}
color.Unset()
fmt.Println()
}
fmt.Println()
// 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 {
fileResults := map[outputFormat]chan status{}
for _, format := range formats {
fileResults[format] = make(chan status, 1)
}
results[i] = fileResults
}
pendingJobs := make(chan job, 256)
// Spawn numCPU job runners...
for cpu := 0; cpu < numCPU; cpu++ {
go func() {
for job := range pendingJobs {
job.run(dir, exe, dxcPath, xcrunPath, generateExpected, generateSkip)
}
}()
}
// Issue the jobs...
go func() {
for i, file := range files { // For each test file...
file := filepath.Join(dir, file)
for _, format := range formats { // For each output format...
pendingJobs <- job{
file: file,
format: format,
result: results[i][format],
}
}
}
close(pendingJobs)
}()
type failure struct {
file string
format outputFormat
err error
}
// Print the table of file x format
failures := []failure{}
numTests, numPass, numSkip, numFail := 0, 0, 0, 0
filenameFmt := columnFormat(maxStringLen(files), false)
fmt.Println()
fmt.Printf(filenameFmt, "")
fmt.Printf(" ┃ ")
for _, format := range formats {
color.Set(color.FgCyan)
fmt.Printf(columnFormat(formatWidth(format), false), format)
color.Unset()
fmt.Printf(" │ ")
}
fmt.Println()
fmt.Printf(strings.Repeat("━", maxStringLen(files)))
fmt.Printf("━╋━")
for _, format := range formats {
fmt.Printf(strings.Repeat("━", formatWidth(format)))
fmt.Printf("━│━")
}
fmt.Println()
for i, file := range files {
results := results[i]
color.Set(color.FgBlue)
fmt.Printf(filenameFmt, file)
color.Unset()
fmt.Printf(" ┃ ")
for _, format := range formats {
formatFmt := columnFormat(formatWidth(format), true)
result := <-results[format]
numTests++
if err := result.err; err != nil {
failures = append(failures, failure{
file: file, format: format, err: err,
})
}
switch result.code {
case pass:
color.Set(color.FgGreen)
fmt.Printf(formatFmt, "PASS")
numPass++
case fail:
color.Set(color.FgRed)
fmt.Printf(formatFmt, "FAIL")
numFail++
case skip:
color.Set(color.FgYellow)
fmt.Printf(formatFmt, "SKIP")
numSkip++
default:
fmt.Printf(formatFmt, result.code)
}
color.Unset()
fmt.Printf(" │ ")
}
fmt.Println()
}
fmt.Println()
for _, f := range failures {
color.Set(color.FgBlue)
fmt.Printf("%s ", f.file)
color.Set(color.FgCyan)
fmt.Printf("%s ", f.format)
color.Set(color.FgRed)
fmt.Println("FAIL")
color.Unset()
fmt.Println(indent(f.err.Error(), 4))
}
if len(failures) > 0 {
fmt.Println()
}
fmt.Printf("%d tests run", numTests)
if numPass > 0 {
fmt.Printf(", ")
color.Set(color.FgGreen)
fmt.Printf("%d tests pass", numPass)
color.Unset()
} else {
fmt.Printf(", %d tests pass", numPass)
}
if numSkip > 0 {
fmt.Printf(", ")
color.Set(color.FgYellow)
fmt.Printf("%d tests skipped", numSkip)
color.Unset()
} else {
fmt.Printf(", %d tests skipped", numSkip)
}
if numFail > 0 {
fmt.Printf(", ")
color.Set(color.FgRed)
fmt.Printf("%d tests failed", numFail)
color.Unset()
} else {
fmt.Printf(", %d tests failed", numFail)
}
fmt.Println()
fmt.Println()
if numFail > 0 {
os.Exit(1)
}
return nil
}
// Structures to hold the results of the tests
type statusCode string
const (
fail statusCode = "FAIL"
pass statusCode = "PASS"
skip statusCode = "SKIP"
)
type status struct {
code statusCode
err error
}
type job struct {
file string
format outputFormat
result chan status
}
func (j job) run(wd, exe, dxcPath, xcrunPath string, generateExpected, generateSkip bool) {
j.result <- func() status {
// Is there an expected output?
expected := loadExpectedFile(j.file, j.format)
skipped := false
if strings.HasPrefix(expected, "SKIP") { // Special SKIP token
skipped = true
}
expected = strings.ReplaceAll(expected, "\r\n", "\n")
file, err := filepath.Rel(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,
"--format", string(j.format),
}
// Can we validate?
validate := false
switch j.format {
case spvasm:
args = append(args, "--validate") // spirv-val is statically linked, always available
validate = true
case hlsl:
if dxcPath != "" {
args = append(args, "--dxc", dxcPath)
validate = true
}
case msl:
if xcrunPath != "" {
args = append(args, "--xcrun", xcrunPath)
validate = true
}
}
// Invoke the compiler...
ok, out := invoke(wd, exe, args...)
out = strings.ReplaceAll(out, "\r\n", "\n")
matched := expected == "" || expected == out
if ok && generateExpected && (validate || !skipped) {
saveExpectedFile(j.file, j.format, out)
matched = true
}
switch {
case ok && matched:
// Test passed
return status{code: pass}
// --- Below this point the test has failed ---
case skipped:
if generateSkip {
saveExpectedFile(j.file, j.format, "SKIP: FAILED\n\n"+out)
}
return status{code: skip}
case !ok:
// Compiler returned non-zero exit code
if generateSkip {
saveExpectedFile(j.file, j.format, "SKIP: FAILED\n\n"+out)
}
err := fmt.Errorf("%s", out)
return status{code: fail, err: err}
default:
// Compiler returned zero exit code, or output was not as expected
if generateSkip {
saveExpectedFile(j.file, j.format, "SKIP: FAILED\n\n"+out)
}
// Expected output did not match
dmp := diffmatchpatch.New()
diff := dmp.DiffPrettyText(dmp.DiffMain(expected, out, true))
err := fmt.Errorf(`Output was not as expected
--------------------------------------------------------------------------------
-- Expected: --
--------------------------------------------------------------------------------
%s
--------------------------------------------------------------------------------
-- Got: --
--------------------------------------------------------------------------------
%s
--------------------------------------------------------------------------------
-- Diff: --
--------------------------------------------------------------------------------
%s`,
expected, out, diff)
return status{code: fail, err: err}
}
}()
}
// loadExpectedFile loads the expected output file for the test file at 'path'
// and the output format 'format'. If the file does not exist, or cannot be
// read, then an empty string is returned.
func loadExpectedFile(path string, format outputFormat) string {
content, err := ioutil.ReadFile(expectedFilePath(path, format))
if err != nil {
return ""
}
return string(content)
}
// saveExpectedFile writes the expected output file for the test file at 'path'
// and the output format 'format', with the content 'content'.
func saveExpectedFile(path string, format outputFormat, content string) error {
return ioutil.WriteFile(expectedFilePath(path, format), []byte(content), 0666)
}
// expectedFilePath returns the expected output file path for the test file at
// 'path' and the output format 'format'.
func expectedFilePath(path string, format outputFormat) string {
return path + ".expected." + string(format)
}
// indent returns the string 's' indented with 'n' whitespace characters
func indent(s string, n int) string {
tab := strings.Repeat(" ", n)
return tab + strings.ReplaceAll(s, "\n", "\n"+tab)
}
// columnFormat returns the printf format string to sprint a string with the
// width of 'i' runes.
func columnFormat(i int, alignLeft bool) string {
if alignLeft {
return "%-" + strconv.Itoa(i) + "s"
}
return "%" + strconv.Itoa(i) + "s"
}
// maxStringLen returns the maximum number of runes found in all the strings in
// 'l'
func maxStringLen(l []string) int {
max := 0
for _, s := range l {
if c := utf8.RuneCountInString(s); c > max {
max = c
}
}
return max
}
// formatWidth returns the width in runes for the outputFormat column 'b'
func formatWidth(b outputFormat) int {
c := utf8.RuneCountInString(string(b))
if c > 4 {
return c
}
return 4
}
// invoke runs the executable 'exe' with the provided arguments.
func invoke(wd, exe string, args ...string) (ok bool, output string) {
cmd := exec.Command(exe, args...)
cmd.Dir = wd
out, err := cmd.CombinedOutput()
str := string(out)
if err != nil {
if str != "" {
return false, str
}
return false, err.Error()
}
return true, str
}