{cmd,internal}/screentest: testcases run concurrently

Testcases for screentest will run concurrently
with a configurable max concurrency setting that
defaults to half of number of CPUs on a system.

Change-Id: I07e7ffd8d3867c47b709c160110a58ac60ee357c
Reviewed-on: https://go-review.googlesource.com/c/website/+/377256
Run-TryBot: Jamal Carvalho <jamal@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Trust: Jamal Carvalho <jamalcarvalho@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/cmd/screentest/main.go b/cmd/screentest/main.go
index a628459..615c324 100644
--- a/cmd/screentest/main.go
+++ b/cmd/screentest/main.go
@@ -2,7 +2,18 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// Command screentest runs the screentest check for a set of scripts.
+/*
+Command screentest runs the screentest check for a set of scripts.
+  Usage: screentest [flags] [glob]
+
+The flags are:
+  -u
+    update cached screenshots
+  -v
+    variables provided to script templates as comma separated KEY:VALUE pairs
+  -c
+    number of testcases to run concurrently
+*/
 package main
 
 import (
@@ -11,19 +22,21 @@
 	"log"
 	"os"
 	"path/filepath"
+	"runtime"
 	"strings"
 
 	"golang.org/x/website/internal/screentest"
 )
 
 var (
-	update = flag.Bool("update", false, "update cached screenshots")
-	vars   = flag.String("vars", "", "provide variables to the script template as comma separated KEY:VALUE pairs")
+	update      = flag.Bool("u", false, "update cached screenshots")
+	vars        = flag.String("v", "", "variables provided to script templates as comma separated KEY:VALUE pairs")
+	concurrency = flag.Int("c", (runtime.NumCPU()+1)/2, "number of testcases to run concurrently")
 )
 
 func main() {
 	flag.Usage = func() {
-		fmt.Printf("Usage: screentest [OPTIONS] glob\n")
+		fmt.Printf("Usage: screentest [flags] [glob]\n")
 		flag.PrintDefaults()
 	}
 	flag.Parse()
@@ -47,7 +60,7 @@
 			parsedVars[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
 		}
 	}
-	if err := screentest.CheckHandler(glob, *update, parsedVars); err != nil {
+	if err := screentest.CheckHandler(glob, *update, *concurrency, parsedVars); err != nil {
 		log.Fatal(err)
 	}
 }
diff --git a/internal/screentest/screentest.go b/internal/screentest/screentest.go
index 7be25f7..6aa2262 100644
--- a/internal/screentest/screentest.go
+++ b/internal/screentest/screentest.go
@@ -130,7 +130,7 @@
 
 // CheckHandler runs the test scripts matched by glob. If any errors are
 // encountered, CheckHandler returns an error listing the problems.
-func CheckHandler(glob string, update bool, vars map[string]string) error {
+func CheckHandler(glob string, update bool, maxConcurrency int, vars map[string]string) error {
 	now := time.Now()
 	ctx := context.Background()
 	files, err := filepath.Glob(glob)
@@ -160,7 +160,8 @@
 		ctx, cancel = chromedp.NewContext(ctx, chromedp.WithLogf(log.Printf))
 		defer cancel()
 		var hdr bool
-		for _, tc := range tests {
+		runConcurrently(len(tests), maxConcurrency, func(i int) {
+			tc := tests[i]
 			if err := tc.run(ctx, update); err != nil {
 				if !hdr {
 					fmt.Fprintf(&buf, "%s\n\n", file)
@@ -170,7 +171,7 @@
 				fmt.Fprintf(&buf, "inspect diff at %s\n\n", tc.outDiff)
 			}
 			fmt.Println(tc.output.String())
-		}
+		})
 	}
 	fmt.Printf("finished in %s\n\n", time.Since(now).Truncate(time.Millisecond))
 	if buf.Len() > 0 {
@@ -180,7 +181,7 @@
 }
 
 // TestHandler runs the test script files matched by glob.
-func TestHandler(t *testing.T, glob string, update bool, vars map[string]string) {
+func TestHandler(t *testing.T, glob string, update, parallel bool, vars map[string]string) {
 	ctx := context.Background()
 	files, err := filepath.Glob(glob)
 	if err != nil {
@@ -206,6 +207,9 @@
 		defer cancel()
 		for _, tc := range tests {
 			t.Run(tc.name, func(t *testing.T) {
+				if parallel {
+					t.Parallel()
+				}
 				if err := tc.run(ctx, update); err != nil {
 					t.Fatal(err)
 				}
@@ -788,3 +792,22 @@
 		}
 	}
 }
+
+// runConcurrently calls f on each integer from 0 to n-1,
+// with at most max invocations active at once.
+// It waits for all invocations to complete.
+func runConcurrently(n, max int, f func(int)) {
+	tokens := make(chan struct{}, max)
+	for i := 0; i < n; i++ {
+		i := i
+		tokens <- struct{}{} // wait until the number of goroutines is below the limit
+		go func() {
+			f(i)
+			<-tokens // let another goroutine run
+		}()
+	}
+	// Wait for all goroutines to finish.
+	for i := 0; i < cap(tokens); i++ {
+		tokens <- struct{}{}
+	}
+}
diff --git a/internal/screentest/screentest_test.go b/internal/screentest/screentest_test.go
index c9b2680..96838ac 100644
--- a/internal/screentest/screentest_test.go
+++ b/internal/screentest/screentest_test.go
@@ -239,7 +239,7 @@
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			if err := CheckHandler(tt.args.glob, false, nil); (err != nil) != tt.wantErr {
+			if err := CheckHandler(tt.args.glob, false, 1, nil); (err != nil) != tt.wantErr {
 				t.Fatalf("CheckHandler() error = %v, wantErr %v", err, tt.wantErr)
 			}
 			if len(tt.wantFiles) != 0 {
@@ -262,7 +262,7 @@
 	if err != nil {
 		t.Skip()
 	}
-	TestHandler(t, "testdata/pass.txt", false, nil)
+	TestHandler(t, "testdata/pass.txt", false, false, nil)
 }
 
 func TestHeaders(t *testing.T) {