[tools] Add `tools/run node` to run a .js file with dawn/node

Change-Id: I797919f73d2c01873cb708bb75c809cdd7b14d31
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/179820
Reviewed-by: Antonio Maiorano <amaiorano@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
Auto-Submit: Ben Clayton <bclayton@google.com>
Commit-Queue: Antonio Maiorano <amaiorano@google.com>
diff --git a/tools/src/cmd/node/main.go b/tools/src/cmd/node/main.go
new file mode 100644
index 0000000..d2dbf54
--- /dev/null
+++ b/tools/src/cmd/node/main.go
@@ -0,0 +1,141 @@
+// 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 main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"dawn.googlesource.com/dawn/tools/src/cmd/run-cts/common"
+	"dawn.googlesource.com/dawn/tools/src/dawn/node"
+	"dawn.googlesource.com/dawn/tools/src/fileutils"
+)
+
+// main entry point
+func main() {
+	nodeFlags := node.Flags{}
+	opts := node.Options{
+		AllowUnsafeAPIs: true,
+	}
+
+	var nodePath string
+	lldb := false
+
+	flag.Usage = func() {
+		out := flag.CommandLine.Output()
+		fmt.Fprintf(out, "node runs a .js file with 'navigator.gpu' provided by dawn/node.\n\n")
+		flag.PrintDefaults()
+	}
+
+	flag.StringVar(&nodePath, "node", fileutils.NodePath(), "path to node executable")
+	flag.Var(&nodeFlags, "flag", "flag to pass to dawn-node as flag=value. multiple flags must be passed in individually")
+	flag.StringVar(&opts.BinDir, "bin", fileutils.BuildPath(), "path to the directory holding cts.js and dawn.node")
+	flag.StringVar(&opts.Backend, "backend", "default", "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.StringVar(&opts.Adapter, "adapter", "", "name (or substring) of the GPU adapter to use")
+	flag.BoolVar(&opts.Validate, "validate", false, "enable backend validation")
+	flag.BoolVar(&opts.DumpShaders, "dump-shaders", false, "dump WGSL shaders. Enables --verbose")
+	flag.BoolVar(&opts.UseFXC, "fxc", false, "Use FXC instead of DXC. Disables 'use_dxc' Dawn flag")
+	flag.BoolVar(&lldb, "lldb", false, "launch node via lldb")
+	flag.Parse()
+
+	nodeFlags.SetOptions(opts)
+
+	debugger := ""
+	if lldb {
+		debugger = "lldb"
+	}
+
+	if err := run(opts.BinDir, nodePath, nodeFlags, flag.Args(), debugger); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+// run starts the
+func run(binPath, nodePath string, flags node.Flags, args []string, debugger string) error {
+	if len(args) == 0 {
+		return fmt.Errorf("missing path to .js file")
+	}
+
+	scriptPath, err := filepath.Abs(args[0])
+	if err != nil {
+		return err
+	}
+
+	// Find node
+	if nodePath == "" {
+		return fmt.Errorf("cannot find path to node. Specify with --node")
+	}
+
+	for _, file := range []string{"cts.js", "dawn.node"} {
+		if !fileutils.IsFile(filepath.Join(binPath, file)) {
+			return fmt.Errorf("'%v' does not contain '%v'", binPath, file)
+		}
+	}
+
+	ctsJS := filepath.Join(binPath, "cts.js")
+
+	ctx := context.Background()
+	if debugger == "" {
+		timeoutCtx, cancel := context.WithTimeout(context.Background(), common.TestCaseTimeout)
+		defer cancel()
+		ctx = timeoutCtx
+	}
+
+	quotedFlags := make([]string, len(flags))
+	for i, f := range flags {
+		quotedFlags[i] = fmt.Sprintf("'%v'", f)
+	}
+
+	args = []string{
+		"-e",
+		fmt.Sprintf("const { create } = require('%v'); navigator = { gpu: create([%v]) }; require('%v')", ctsJS, strings.Join(quotedFlags, ", "), scriptPath),
+	}
+
+	exe := nodePath
+
+	if debugger != "" {
+		args = append([]string{"--", exe}, args...)
+		exe, err = exec.LookPath(debugger)
+		if err != nil {
+			return err
+		}
+	}
+
+	cmd := exec.CommandContext(ctx, exe, args...)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}
diff --git a/tools/src/cmd/run-cts/node/cmd.go b/tools/src/cmd/run-cts/node/cmd.go
index cd7d35a..4edea34 100644
--- a/tools/src/cmd/run-cts/node/cmd.go
+++ b/tools/src/cmd/run-cts/node/cmd.go
@@ -35,64 +35,14 @@
 	"os/exec"
 	"path/filepath"
 	"runtime"
-	"strings"
 
 	"dawn.googlesource.com/dawn/tools/src/cmd/run-cts/common"
 	"dawn.googlesource.com/dawn/tools/src/cov"
+	"dawn.googlesource.com/dawn/tools/src/dawn/node"
 	"dawn.googlesource.com/dawn/tools/src/fileutils"
 	"dawn.googlesource.com/dawn/tools/src/subcmd"
 )
 
-type dawnFlags []string
-
-func (f *dawnFlags) String() string {
-	return strings.Join(*f, "")
-}
-
-func (f *dawnFlags) Set(value string) error {
-	// Multiple flags must be passed in individually:
-	// -flag=a=b -dawn_node_flag=c=d
-	*f = append(*f, value)
-	return nil
-}
-
-// Consolidates all the delimiter separated flags with a given prefix into a single flag.
-// Example:
-// Given the flags: ["foo=a", "bar", "foo=b,c"]
-// GlobListFlags("foo=", ",") will transform the flags to: ["bar", "foo=a,b,c"]
-func (f *dawnFlags) GlobListFlags(prefix string, delimiter string) {
-	list := []string{}
-	i := 0
-	for _, flag := range *f {
-		if strings.HasPrefix(flag, prefix) {
-			// Trim the prefix.
-			value := flag[len(prefix):]
-			// Extract the deliminated values.
-			list = append(list, strings.Split(value, delimiter)...)
-		} else {
-			(*f)[i] = flag
-			i++
-		}
-	}
-	(*f) = (*f)[:i]
-	if len(list) > 0 {
-		// Append back the consolidated flags.
-		f.Set(prefix + strings.Join(list, delimiter))
-	}
-}
-
-// defaultBinPath looks for the binary output directory at <dawn>/out/active.
-// This is used as the default for the --bin command line flag.
-func defaultBinPath() string {
-	if dawnRoot := fileutils.DawnRoot(); dawnRoot != "" {
-		bin := filepath.Join(dawnRoot, "out/active")
-		if info, err := os.Stat(bin); err == nil && info.IsDir() {
-			return bin
-		}
-	}
-	return ""
-}
-
 type flags struct {
 	common.Flags
 	bin                  string
@@ -108,7 +58,7 @@
 	genCoverage          bool
 	compatibilityMode    bool
 	skipVSCodeInfo       bool
-	dawn                 dawnFlags
+	dawn                 node.Flags
 }
 
 func init() {
@@ -141,7 +91,7 @@
 	}
 
 	c.flags.Flags.Register()
-	flag.StringVar(&c.flags.bin, "bin", defaultBinPath(), "path to the directory holding cts.js and dawn.node")
+	flag.StringVar(&c.flags.bin, "bin", fileutils.BuildPath(), "path to the directory holding cts.js and dawn.node")
 	flag.BoolVar(&c.flags.isolated, "isolate", false, "run each test in an isolated process")
 	flag.BoolVar(&c.flags.build, "build", true, "attempt to build the CTS before running")
 	flag.BoolVar(&c.flags.validate, "validate", false, "enable backend validation")
@@ -245,31 +195,19 @@
 		return fmt.Errorf("only a single query can be provided")
 	}
 
-	// For Windows, set the DLL directory to bin so that Dawn loads dxcompiler.dll from there.
-	c.flags.dawn.Set("dlldir=" + c.flags.bin)
+	c.flags.dawn.SetOptions(node.Options{
+		BinDir:          c.flags.bin,
+		Backend:         c.flags.backend,
+		Adapter:         c.flags.adapterName,
+		Validate:        c.flags.validate,
+		AllowUnsafeAPIs: true,
+		DumpShaders:     c.flags.dumpShaders,
+		UseFXC:          c.flags.fxc,
+	})
 
-	// Forward the backend and adapter to use, if specified.
-	if c.flags.backend != "default" {
-		fmt.Println("Forcing backend to", c.flags.backend)
-		c.flags.dawn.Set("backend=" + c.flags.backend)
-	}
-	if c.flags.adapterName != "" {
-		c.flags.dawn.Set("adapter=" + c.flags.adapterName)
-	}
-	if c.flags.validate {
-		c.flags.dawn.Set("validate=1")
-	}
-
-	// While running the CTS, always allow unsafe APIs so they can be tested.
-	c.flags.dawn.Set("enable-dawn-features=allow_unsafe_apis")
 	if c.flags.dumpShaders {
 		c.flags.Verbose = true
-		c.flags.dawn.Set("enable-dawn-features=dump_shaders,disable_symbol_renaming")
 	}
-	if c.flags.fxc {
-		c.flags.dawn.Set("disable-dawn-features=use_dxc")
-	}
-	c.flags.dawn.GlobListFlags("enable-dawn-features=", ",")
 
 	state, err := c.flags.Process()
 	if err != nil {
diff --git a/tools/src/dawn/node/node.go b/tools/src/dawn/node/node.go
new file mode 100644
index 0000000..fbacee1
--- /dev/null
+++ b/tools/src/dawn/node/node.go
@@ -0,0 +1,115 @@
+// 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 node holds common code for running dawn/node
+package node
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Flags for running dawn/node
+type Flags []string
+
+func (f *Flags) String() string {
+	return strings.Join(*f, "")
+}
+
+// Set sets a dawn flag
+func (f *Flags) Set(value string) error {
+	// Multiple flags must be passed in individually:
+	// -flag=a=b -dawn_node_flag=c=d
+	*f = append(*f, value)
+	return nil
+}
+
+// Consolidates all the delimiter separated flags with a given prefix into a single flag.
+// Example:
+// Given the flags: ["foo=a", "bar", "foo=b,c"]
+// GlobListFlags("foo=", ",") will transform the flags to: ["bar", "foo=a,b,c"]
+func (f *Flags) GlobListFlags(prefix string, delimiter string) {
+	list := []string{}
+	i := 0
+	for _, flag := range *f {
+		if strings.HasPrefix(flag, prefix) {
+			// Trim the prefix.
+			value := flag[len(prefix):]
+			// Extract the deliminated values.
+			list = append(list, strings.Split(value, delimiter)...)
+		} else {
+			(*f)[i] = flag
+			i++
+		}
+	}
+	(*f) = (*f)[:i]
+	if len(list) > 0 {
+		// Append back the consolidated flags.
+		f.Set(prefix + strings.Join(list, delimiter))
+	}
+}
+
+// Options that can be passed to Flags.SetOptions
+type Options struct {
+	BinDir          string
+	Backend         string
+	Adapter         string
+	Validate        bool
+	AllowUnsafeAPIs bool
+	DumpShaders     bool
+	UseFXC          bool
+}
+
+func (f *Flags) SetOptions(opts Options) error {
+	// For Windows, set the DLL directory to bin so that Dawn loads dxcompiler.dll from there.
+	f.Set("dlldir=" + opts.BinDir)
+
+	// Forward the backend and adapter to use, if specified.
+	if opts.Backend != "" && opts.Backend != "default" {
+		fmt.Println("Forcing backend to", opts.Backend)
+		f.Set("backend=" + opts.Backend)
+	}
+	if opts.Adapter != "" {
+		f.Set("adapter=" + opts.Adapter)
+	}
+	if opts.Validate {
+		f.Set("validate=1")
+	}
+
+	if opts.AllowUnsafeAPIs {
+		f.Set("enable-dawn-features=allow_unsafe_apis")
+	}
+	if opts.DumpShaders {
+		f.Set("enable-dawn-features=dump_shaders,disable_symbol_renaming")
+	}
+	if opts.UseFXC {
+		f.Set("disable-dawn-features=use_dxc")
+	}
+	f.GlobListFlags("enable-dawn-features=", ",")
+
+	return nil
+}
diff --git a/tools/src/fileutils/paths.go b/tools/src/fileutils/paths.go
index a2edcfb..5070b56 100644
--- a/tools/src/fileutils/paths.go
+++ b/tools/src/fileutils/paths.go
@@ -120,6 +120,18 @@
 	return ""
 }
 
+// BuildPath looks for the binary output directory at '<dawn>/out/active'.
+// Returns the path if found, otherwise an empty string.
+func BuildPath() string {
+	if dawnRoot := DawnRoot(); dawnRoot != "" {
+		bin := filepath.Join(dawnRoot, "out/active")
+		if info, err := os.Stat(bin); err == nil && info.IsDir() {
+			return bin
+		}
+	}
+	return ""
+}
+
 // IsDir returns true if the path resolves to a directory
 func IsDir(path string) bool {
 	s, err := os.Stat(path)