cmd/screentest: miscellaneous minor changes

- Rename -u to -update to reduce chance of error.

- Remove entire failure directory so that index.html
  is deleted too.

- Tweak user output.

- Replace url.JoinPath with a custom function that does not
  escape paths.

- Fix bug where context is canceled when writing to GCS.

Change-Id: I2762d4c226f8a0592e2720846ec118aeac49d0cf
Reviewed-on: https://go-review.googlesource.com/c/website/+/630175
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/cmd/screentest/main.go b/cmd/screentest/main.go
index 8ed3c7e..0105cde 100644
--- a/cmd/screentest/main.go
+++ b/cmd/screentest/main.go
@@ -170,7 +170,7 @@
 var flags options
 
 func init() {
-	flag.BoolVar(&flags.update, "u", false, "update want with test")
+	flag.BoolVar(&flags.update, "update", false, "update want with test")
 	flag.StringVar(&flags.vars, "v", "", "variables provided to script templates as comma separated KEY:VALUE pairs")
 	flag.IntVar(&flags.maxConcurrency, "c", (runtime.NumCPU()+1)/2, "number of test cases to run concurrently")
 	flag.StringVar(&flags.debuggerURL, "d", "", "chrome debugger URL")
@@ -209,7 +209,5 @@
 	}
 	if err := run(context.Background(), flag.Arg(0), flag.Arg(1), flag.Args()[2:], flags); err != nil {
 		log.Fatal(err)
-	} else {
-		log.Print("PASS")
 	}
 }
diff --git a/cmd/screentest/screentest.go b/cmd/screentest/screentest.go
index 449c753..3542f57 100644
--- a/cmd/screentest/screentest.go
+++ b/cmd/screentest/screentest.go
@@ -73,8 +73,14 @@
 		return err
 	}
 
+	// Remove fail directory and all contents, to avoid confusion with previous runs.
+	if err := c.failImageWriter.rmdir(ctx, "."); err != nil {
+		return err
+	}
+
 	var (
 		summary     bytes.Buffer
+		nTests      int         // number of tests run
 		failedTests []*testcase // tests that failed and wrote diffs
 	)
 	for _, file := range files {
@@ -82,16 +88,14 @@
 		if err != nil {
 			return err
 		}
-		if len(tests) == 0 && opts.filterRegexp == "" {
-			return fmt.Errorf("no tests found in %q", file)
-		}
 
-		// Remove fail directory and all contents, to avoid confusion with previous runs.
-		if err := c.failImageWriter.rmdir(ctx, fileDir(file)); err != nil {
-			return err
+		if len(tests) == 0 {
+			continue
 		}
+		nTests += len(tests)
 
 		ctx, cancel = chromedp.NewContext(ctx, chromedp.WithLogf(log.Printf))
+		// TODO(jba): cancel after each iteration
 		defer cancel()
 
 		if opts.maxConcurrency < 1 {
@@ -119,7 +123,11 @@
 			fmt.Println(tc.output.String())
 		})
 	}
-	fmt.Printf("finished in %s\n\n", time.Since(start).Truncate(time.Millisecond))
+	if nTests == 0 {
+		log.Print("no tests to run")
+		return nil
+	}
+	log.Printf("ran %d tests in %s\n\n", nTests, time.Since(start).Truncate(time.Millisecond))
 	if summary.Len() > 0 {
 		os.Stdout.Write(summary.Bytes())
 		if len(failedTests) > 0 {
@@ -130,6 +138,11 @@
 		}
 		return fmt.Errorf("FAIL. Output at %s", c.failImageWriter.path())
 	}
+	if opts.update {
+		log.Print("UPDATED")
+	} else {
+		log.Print("PASS")
+	}
 	return nil
 }
 
@@ -378,17 +391,11 @@
 			test.path = args
 			// If there is no imageReader, then assume the URL is an http(s) URL.
 			if common.testImageReader == nil {
-				test.testURL, err = url.JoinPath(testURL, test.path)
-				if err != nil {
-					return nil, err
-				}
+				test.testURL = joinURL(testURL, test.path)
 			}
 			// Ditto for want.
 			if common.wantImageReadWriter == nil {
-				test.wantURL, err = url.JoinPath(wantURL, test.path)
-				if err != nil {
-					return nil, err
-				}
+				test.wantURL = joinURL(wantURL, test.path)
 			}
 
 		case "CLICK":
@@ -486,6 +493,14 @@
 	return tests, nil
 }
 
+// joinURL joins the left and right parts of a URL
+// with a slash.
+// Unlike url.JoinPath, it does not escape the URL path.
+// Unlike path.Join, it does not remove doubled slashes.
+func joinURL(left, right string) string {
+	return strings.TrimSuffix(left, "/") + "/" + strings.TrimPrefix(right, "/")
+}
+
 // executeFileTemplate reads file and executes it with text/template, passing vars as the argument.
 func executeFileTemplate(file string, vars map[string]string) ([]byte, error) {
 	tmpl := template.New(filepath.Base(file)).Funcs(template.FuncMap{
@@ -581,14 +596,14 @@
 	now := time.Now()
 	fmt.Fprintf(&tc.output, "test %s ", tc.name)
 	var testScreen, wantScreen image.Image
-	g, ctx := errgroup.WithContext(ctx)
+	g, gctx := errgroup.WithContext(ctx)
 	g.Go(func() error {
-		testScreen, err = tc.screenshot(ctx, tc.testURL, tc.testPath, tc.testImageReader)
+		testScreen, err = tc.screenshot(gctx, tc.testURL, tc.testPath, tc.testImageReader)
 		return err
 	})
 	if !update {
 		g.Go(func() error {
-			wantScreen, err = tc.screenshot(ctx, tc.wantURL, tc.wantPath, tc.wantImageReadWriter)
+			wantScreen, err = tc.screenshot(gctx, tc.wantURL, tc.wantPath, tc.wantImageReadWriter)
 			return err
 		})
 	}
@@ -599,7 +614,7 @@
 
 	// Update means overwrite the golden with the test result.
 	if update {
-		fmt.Fprintf(&tc.output, "updating %s\n", tc.wantURL)
+		fmt.Fprintf(&tc.output, "- updating %s", tc.wantURL)
 		return tc.wantImageReadWriter.writeImage(ctx, tc.wantPath, testScreen)
 	}
 
@@ -614,7 +629,7 @@
 	}
 	fmt.Fprintf(&tc.output, "(%s)\n    FAIL %s != %s (%d pixels differ)\n",
 		since, tc.testOrigin(), tc.wantOrigin(), result.DiffPixelsCount)
-	g, gctx := errgroup.WithContext(ctx)
+	g, gctx = errgroup.WithContext(ctx)
 	g.Go(func() error { return tc.failImageWriter.writeImage(gctx, tc.testPath, testScreen) })
 	g.Go(func() error { return tc.failImageWriter.writeImage(gctx, tc.wantPath, wantScreen) })
 	g.Go(func() error { return tc.failImageWriter.writeImage(gctx, tc.diffPath, result.Image) })
@@ -721,11 +736,11 @@
 	return func(ctx context.Context) error {
 		ch := make(chan struct{})
 		cctx, cancel := context.WithCancel(ctx)
+		defer cancel()
 		chromedp.ListenTarget(cctx, func(ev any) {
 			switch e := ev.(type) {
 			case *page.EventLifecycleEvent:
 				if e.Name == eventName {
-					cancel()
 					close(ch)
 				}
 			}
@@ -821,14 +836,16 @@
 
 func (rw *dirImageReadWriter) readImage(_ context.Context, path string) (_ image.Image, err error) {
 	path = rw.nativePathname(path)
-	defer wraperr(&err, "reading image from %s", path)
 	f, err := os.Open(path)
 	if err != nil {
 		return nil, err
 	}
 	defer f.Close()
 	img, _, err := image.Decode(f)
-	return img, err
+	if err != nil {
+		return nil, fmt.Errorf("decoding image from %s: %w", path, err)
+	}
+	return img, nil
 }
 
 func (rw *dirImageReadWriter) writeImage(ctx context.Context, path string, img image.Image) error {
@@ -918,12 +935,8 @@
 	cctx, cancel := context.WithCancel(ctx)
 	defer cancel()
 	w := rw.bucket.Object(rw.objectName(pth)).NewWriter(cctx)
-	if _, err := w.Write(data); err != nil {
-		cancel()
-		_ = w.Close()
-		return err
-	}
-	return w.Close()
+	_, err = w.Write(data)
+	return errors.Join(err, w.Close())
 }
 
 func (rw *gcsImageReadWriter) rmdir(ctx context.Context, pth string) (err error) {