internal/screentest: add package to compare screenshots web pages

As we clean up page styles post golang.org and go.dev merge,
and begin to implement the dark theme, we can use screenshot
testing to check that code changes don't result in unintended
style or html content changes.

Change-Id: Ia65f98f133df0f4a9fa3f382fd30c2455d65d73d
Reviewed-on: https://go-review.googlesource.com/c/website/+/371435
Run-TryBot: Jamal Carvalho <jamal@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Trust: Jonathan Amsterdam <jba@google.com>
Trust: Jamal Carvalho <jamalcarvalho@google.com>
diff --git a/cmd/screentest/main.go b/cmd/screentest/main.go
new file mode 100644
index 0000000..a60c103
--- /dev/null
+++ b/cmd/screentest/main.go
@@ -0,0 +1,23 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Command screentest runs the visual diff check for the set of scripts
+// provided by the flag -testdata.
+package main
+
+import (
+	"flag"
+	"log"
+
+	"golang.org/x/website/internal/screentest"
+)
+
+var testdata = flag.String("testdata", "cmd/screentest/testdata/*.txt", "directory to look for testdata")
+
+func main() {
+	flag.Parse()
+	if err := screentest.CheckHandler(*testdata); err != nil {
+		log.Fatal(err)
+	}
+}
diff --git a/cmd/screentest/testdata/godev.txt b/cmd/screentest/testdata/godev.txt
new file mode 100644
index 0000000..f92ab57
--- /dev/null
+++ b/cmd/screentest/testdata/godev.txt
@@ -0,0 +1,32 @@
+windowsize 1536x960
+compare https://go.dev http://localhost:6060/go.dev
+
+test homepage
+pathname /
+capture fullscreen
+capture fullscreen 540x1080
+
+test why go
+pathname /solutions/
+capture fullscreen
+capture fullscreen 540x1080
+
+test getting started
+pathname /learn/
+capture fullscreen
+capture fullscreen 540x1080
+
+test docs
+pathname /doc/
+capture fullscreen
+capture fullscreen 540x1080
+
+test playground
+pathname /play/
+capture fullscreen
+capture fullscreen 540x1080
+
+test blog
+pathname /blog/
+capture fullscreen
+capture fullscreen 540x1080
diff --git a/go.mod b/go.mod
index 0be1d74..0f7895d 100644
--- a/go.mod
+++ b/go.mod
@@ -5,12 +5,16 @@
 require (
 	cloud.google.com/go v0.88.0
 	cloud.google.com/go/datastore v1.2.0
+	github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4
+	github.com/chromedp/chromedp v0.7.6
 	github.com/gomodule/redigo v2.0.0+incompatible
 	github.com/google/go-cmp v0.5.6
 	github.com/microcosm-cc/bluemonday v1.0.2
+	github.com/n7olkachev/imgdiff v1.0.2
 	github.com/yuin/goldmark v1.3.5
 	golang.org/x/build v0.0.0-20211102155042-c046fca86e58
 	golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985
+	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
 	golang.org/x/tools v0.1.5
 	google.golang.org/api v0.51.0
 	google.golang.org/genproto v0.0.0-20210726143408-b02e89920bf0
diff --git a/go.sum b/go.sum
index 52cf508..8acfecf 100644
--- a/go.sum
+++ b/go.sum
@@ -77,6 +77,8 @@
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/alexflint/go-arg v1.3.0/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM=
+github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0=
@@ -131,6 +133,13 @@
 github.com/cenkalti/backoff/v4 v4.0.2/go.mod h1:eEew/i+1Q6OrCDZh3WiXYv3+nJwBASZ8Bog/87DQnVg=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chromedp/cdproto v0.0.0-20211126220118-81fa0469ad77/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
+github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4 h1:St4rQbn3gGWL59ygb4NBxchIeAIW0CTz5Kw4m5JTemU=
+github.com/chromedp/cdproto v0.0.0-20211205231339-d2673e93eee4/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
+github.com/chromedp/chromedp v0.7.6 h1:2juGaktzjwULlsn+DnvIZXFUckEp5xs+GOBroaea+jA=
+github.com/chromedp/chromedp v0.7.6/go.mod h1:ayT4YU/MGAALNfOg9gNrpGSAdnU51PMx+FCeuT1iXzo=
+github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
+github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -230,6 +239,12 @@
 github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ=
 github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0=
 github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
+github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
 github.com/gocql/gocql v0.0.0-20190301043612-f6df8288f9b4/go.mod h1:4Fw1eo5iaEhDUs8XyuhSVCVy52Jq3L+/3GJgYkwc+/0=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -441,6 +456,8 @@
 github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -484,7 +501,10 @@
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
 github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
+github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
 github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
 github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
 github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
@@ -525,6 +545,8 @@
 github.com/mutecomm/go-sqlcipher/v4 v4.4.0/go.mod h1:PyN04SaWalavxRGH9E8ZftG6Ju7rsPrGmQRjrEaVpiY=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/n7olkachev/imgdiff v1.0.2 h1:qVnJMhcDvsrB7KOcLXWW1lLBkNbvRzscwjvDfFf3Ddg=
+github.com/n7olkachev/imgdiff v1.0.2/go.mod h1:7tMX8V2Gp4x3QXnslCYBc/7amMQz/tALbvuMiBUz4d0=
 github.com/nakagami/firebirdsql v0.0.0-20190310045651-3c02a58cfed8/go.mod h1:86wM1zFnC6/uDBfZGNwB65O+pR2OFi5q/YQaEUid1qA=
 github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
 github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
@@ -556,6 +578,7 @@
 github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
 github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
 github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4=
+github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
 github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
 github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
@@ -835,6 +858,7 @@
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -893,6 +917,7 @@
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -911,6 +936,8 @@
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc=
+golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/internal/screentest/screentest.go b/internal/screentest/screentest.go
new file mode 100644
index 0000000..49e432c
--- /dev/null
+++ b/internal/screentest/screentest.go
@@ -0,0 +1,519 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package screentest implements script-based visual diff testing
+// for webpages.
+//
+// Scripts
+//
+// A script is a text file containing a sequence of testcases, separated by
+// blank lines. Lines beginning with # characters are ignored as comments. A
+// testcase is a sequence of lines describing actions to take on a page, along
+// with the dimensions of the screenshots to be compared. For example, here is
+// a trivial script:
+//
+//  compare https://go.dev http://localhost:6060
+//  pathname /about
+//  capture fullscreen
+//
+// This script has a single testcase. The first line sets the origin servers to
+// compare. The second line sets the page to visit at each origin. The last line
+// captures fullpage screenshots of the pages and generates a diff image if they
+// do not match.
+//
+// Keywords
+//
+// Use windowsize WIDTHxHEIGHT to set the default window size for all testcases
+// that follow.
+//
+//  windowsize 540x1080
+//
+// Use compare ORIGIN ORIGIN to set the origins to compare.
+//
+//  compare https://go.dev http://localhost:6060
+//
+// Use test NAME to create a name for the testcase.
+//
+//  test about page
+//
+// Use pathname PATH to set the page to visit at each origin. If no
+// test name is set, PATH will be used as the name for the test.
+//
+//  pathname /about
+//
+// Use click SELECTOR to add a click an element on the page.
+//
+//  click button.submit
+//
+// Use wait SELECTOR to wait for an element to appear.
+//
+//  wait [role="treeitem"][aria-expanded="true"]
+//
+// Use capture [SIZE] [ARG] to create a testcase with the properties
+// defined above.
+//
+//  capture fullscreen 540x1080
+//
+// When taking an element screenshot provide a selector.
+//
+//  capture element header
+//
+// Chain capture commands to create multiple testcases for a single page.
+//
+//  windowsize 1536x960
+//  compare https://go.dev http://localhost:6060
+//
+//  test homepage
+//  pathname /
+//  capture viewport
+//  capture viewport 540x1080
+//  capture viewport 400x1000
+//
+//  test about page
+//  pathname /about
+//  capture viewport
+//  capture viewport 540x1080
+//  capture viewport 400x1000
+//
+package screentest
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"image"
+	"image/png"
+	"log"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/chromedp/cdproto/page"
+	"github.com/chromedp/chromedp"
+	"github.com/n7olkachev/imgdiff/pkg/imgdiff"
+	"golang.org/x/sync/errgroup"
+)
+
+// CheckHandler runs the test scripts matched by glob. If any errors are
+// encountered, CheckHandler returns an error listing the problems.
+func CheckHandler(glob string) error {
+	ctx := context.Background()
+	files, err := filepath.Glob(glob)
+	if err != nil {
+		return fmt.Errorf("filepath.Glob(%q): %w", glob, err)
+	}
+	if len(files) == 0 {
+		return fmt.Errorf("no files match %q", glob)
+	}
+	ctx, cancel := chromedp.NewExecAllocator(ctx, append(
+		chromedp.DefaultExecAllocatorOptions[:],
+		chromedp.WindowSize(browserWidth, browserHeight),
+	)...)
+	defer cancel()
+	var buf bytes.Buffer
+	for _, file := range files {
+		tests, err := readTests(file)
+		if err != nil {
+			return fmt.Errorf("readTestdata(%q): %w", file, err)
+		}
+		if len(tests) == 0 {
+			return fmt.Errorf("no tests found in %q", file)
+		}
+		ctx, cancel = chromedp.NewContext(ctx, chromedp.WithLogf(log.Printf))
+		defer cancel()
+		var hdr bool
+		out, err := outDir(file)
+		if err != nil {
+			return fmt.Errorf("outDir(%q): %w", file, err)
+		}
+		for _, test := range tests {
+			if err := runDiff(ctx, test, out); err != nil {
+				if !hdr {
+					fmt.Fprintf(&buf, "%s\n", file)
+					fmt.Fprintf(&buf, "inspect diffs at %s\n", out)
+					hdr = true
+				}
+				fmt.Fprintf(&buf, "%v\n", err)
+			}
+		}
+	}
+	if buf.Len() > 0 {
+		return errors.New(buf.String())
+	}
+	return nil
+}
+
+// TestHandler runs the test script files matched by glob.
+func TestHandler(t *testing.T, glob string) error {
+	ctx := context.Background()
+	files, err := filepath.Glob(glob)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(files) == 0 {
+		return fmt.Errorf("no files match %#q", glob)
+	}
+	ctx, cancel := chromedp.NewExecAllocator(ctx, append(
+		chromedp.DefaultExecAllocatorOptions[:],
+		chromedp.WindowSize(browserWidth, browserHeight),
+	)...)
+	defer cancel()
+	for _, file := range files {
+		tests, err := readTests(file)
+		if err != nil {
+			t.Fatal(err)
+		}
+		ctx, cancel = chromedp.NewContext(ctx, chromedp.WithLogf(t.Logf))
+		defer cancel()
+		out, err := outDir(file)
+		if err != nil {
+			return fmt.Errorf("outDir(%q): %w", file, err)
+		}
+		for _, test := range tests {
+			t.Run(test.name, func(t *testing.T) {
+				if err := runDiff(ctx, test, out); err != nil {
+					t.Fatal(err)
+				}
+			})
+		}
+	}
+	return nil
+}
+
+// outDir prepares a diff output directory for a given testfile.
+// It empties the directory if it already exists.
+func outDir(testfile string) (string, error) {
+	d, err := os.UserCacheDir()
+	if err != nil {
+		return "", fmt.Errorf("os.UserCacheDir(): %w", err)
+	}
+	out := filepath.Join(d, "screentest", sanitized(filepath.Base(testfile)))
+	err = os.RemoveAll(out)
+	if err != nil {
+		return "", fmt.Errorf("os.RemoveAll(%q): %w", out, err)
+	}
+	err = os.MkdirAll(out, os.ModePerm)
+	if err != nil {
+		return "", fmt.Errorf("os.MkdirAll(%q): %w", out, err)
+	}
+	return out, nil
+}
+
+const (
+	browserWidth  = 1536
+	browserHeight = 960
+)
+
+var sanitize = regexp.MustCompile("[.*<>?`'|/\\: ]")
+
+type screenshotType int
+
+const (
+	fullScreenshot screenshotType = iota
+	viewportScreenshot
+	elementScreenshot
+)
+
+type testcase struct {
+	name              string
+	pathame           string
+	tasks             chromedp.Tasks
+	originA           string
+	originB           string
+	viewportWidth     int
+	viewportHeight    int
+	screenshotType    screenshotType
+	screenshotElement string
+}
+
+// readTests parses the testcases from a text file.
+func readTests(file string) ([]*testcase, error) {
+	f, err := os.Open(file)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	var tests []*testcase
+	var (
+		testName, pathname string
+		tasks              chromedp.Tasks
+		originA, originB   string
+		width, height      int
+		lineNo             int
+	)
+	scan := bufio.NewScanner(f)
+	for scan.Scan() {
+		lineNo += 1
+		line := strings.TrimSpace(scan.Text())
+		if strings.HasPrefix(line, "#") {
+			continue
+		}
+		line = strings.TrimRight(line, " \t")
+		field, args := splitOneField(line)
+		field = strings.ToUpper(field)
+		switch field {
+		case "":
+			// We've reached an empty line, reset properties scoped to a single test.
+			testName = ""
+			pathname = ""
+			tasks = nil
+		case "COMPARE":
+			origins := strings.Split(args, " ")
+			originA, originB = origins[0], origins[1]
+			if _, err := url.Parse(originA); err != nil {
+				return nil, fmt.Errorf("url.Parse(%q): %w", originA, err)
+			}
+			if _, err := url.Parse(originB); err != nil {
+				return nil, fmt.Errorf("url.Parse(%q): %w", originB, err)
+			}
+		case "WINDOWSIZE":
+			width, height, err = splitDimensions(args)
+			if err != nil {
+				return nil, fmt.Errorf("splitDimensions(%q): %w", args, err)
+			}
+		case "TEST":
+			testName = args
+			for _, t := range tests {
+				if t.name == testName {
+					return nil, fmt.Errorf(
+						"duplicate test name %q on line %d", testName, lineNo)
+				}
+			}
+		case "PATHNAME":
+			if _, err := url.Parse(originA + args); err != nil {
+				return nil, fmt.Errorf("url.Parse(%q): %w", originA+args, err)
+			}
+			if _, err := url.Parse(originB + args); err != nil {
+				return nil, fmt.Errorf("url.Parse(%q): %w", originB+args, err)
+			}
+			pathname = args
+			if testName == "" {
+				testName = pathname
+			}
+			for _, t := range tests {
+				if t.name == testName {
+					return nil, fmt.Errorf(
+						"duplicate test with pathname %q on line %d", pathname, lineNo)
+				}
+			}
+		case "CLICK":
+			tasks = append(tasks, chromedp.Click(args))
+		case "WAIT":
+			tasks = append(tasks, chromedp.WaitReady(args))
+		case "CAPTURE":
+			if originA == "" || originB == "" {
+				return nil, fmt.Errorf("missing compare for capture on line %d", lineNo)
+			}
+			if pathname == "" {
+				return nil, fmt.Errorf("missing pathname for capture on line %d", lineNo)
+			}
+			test := &testcase{
+				name:    testName,
+				pathame: pathname,
+				tasks:   tasks,
+				originA: originA,
+				originB: originB,
+				// Default to viewportScreenshot
+				screenshotType: viewportScreenshot,
+				viewportWidth:  width,
+				viewportHeight: height,
+			}
+			tests = append(tests, test)
+			field, args := splitOneField(args)
+			field = strings.ToUpper(field)
+			switch field {
+			case "FULLSCREEN", "VIEWPORT":
+				if field == "FULLSCREEN" {
+					test.screenshotType = fullScreenshot
+				}
+				if args != "" {
+					w, h, err := splitDimensions(args)
+					if err != nil {
+						return nil, fmt.Errorf("splitDimensions(%q): %w", args, err)
+					}
+					test.name = testName + fmt.Sprintf(" %dx%d", w, h)
+					test.viewportWidth = w
+					test.viewportHeight = h
+				}
+			case "ELEMENT":
+				test.name = testName + fmt.Sprintf(" %s", args)
+				test.screenshotType = elementScreenshot
+				test.screenshotElement = args
+			}
+		default:
+			// We should never reach this error.
+			return nil, fmt.Errorf("invalid syntax on line %d: %q", lineNo, line)
+		}
+	}
+	if err := scan.Err(); err != nil {
+		return nil, fmt.Errorf("scan.Err(): %v", err)
+	}
+	return tests, nil
+}
+
+// splitOneField splits text at the first space or tab
+// and returns that first field and the remaining text.
+func splitOneField(text string) (field, rest string) {
+	i := strings.IndexAny(text, " \t")
+	if i < 0 {
+		return text, ""
+	}
+	return text[:i], strings.TrimLeft(text[i:], " \t")
+}
+
+// splitDimensions parses a window dimension string into int values
+// for width and height.
+func splitDimensions(text string) (width, height int, err error) {
+	windowsize := strings.Split(text, "x")
+	if len(windowsize) != 2 {
+		return width, height, fmt.Errorf("syntax error: windowsize %s", text)
+	}
+	width, err = strconv.Atoi(windowsize[0])
+	if err != nil {
+		return width, height, fmt.Errorf("strconv.Atoi(%q): %w", windowsize[0], err)
+	}
+	height, err = strconv.Atoi(windowsize[1])
+	if err != nil {
+		return width, height, fmt.Errorf("strconv.Atoi(%q): %w", windowsize[1], err)
+	}
+	return width, height, nil
+}
+
+// runDiff generates screenshots for a given test case and
+// a diff if the screenshots do not match.
+func runDiff(ctx context.Context, test *testcase, out string) error {
+	fmt.Printf("test %s\n", test.name)
+	urlA, err := url.Parse(test.originA + test.pathame)
+	if err != nil {
+		return fmt.Errorf("url.Parse(%q): %w", test.originA+test.pathame, err)
+	}
+	urlB, err := url.Parse(test.originB + test.pathame)
+	if err != nil {
+		return fmt.Errorf("url.Parse(%q): %w", test.originB+test.pathame, err)
+	}
+	screenA, err := captureScreenshot(ctx, urlA, test)
+	if err != nil {
+		return fmt.Errorf("fullScreenshot(ctx, %q, %q): %w", urlA, test, err)
+	}
+	screenB, err := captureScreenshot(ctx, urlB, test)
+	if err != nil {
+		return fmt.Errorf("fullScreenshot(ctx, %q, %q): %w", urlB, test, err)
+	}
+	if bytes.Equal(screenA, screenB) {
+		fmt.Printf("%s == %s\n\n", urlA, urlB)
+		return nil
+	}
+	fmt.Printf("%s != %s\n", urlA, urlB)
+	imgA, _, err := image.Decode(bytes.NewReader(screenA))
+	if err != nil {
+		return fmt.Errorf("image.Decode(...): %w", err)
+	}
+	imgB, _, err := image.Decode(bytes.NewReader(screenB))
+	if err != nil {
+		return fmt.Errorf("image.Decode(...): %w", err)
+	}
+	outfile := filepath.Join(out, sanitized(test.name))
+	var errs errgroup.Group
+	errs.Go(func() error {
+		out := imgdiff.Diff(imgA, imgB, &imgdiff.Options{
+			Threshold: 0.1,
+			DiffImage: true,
+		})
+		return writePNG(&out.Image, outfile+".diff")
+	})
+	errs.Go(func() error {
+		return writePNG(&imgA, outfile+"."+sanitized(urlA.Host))
+	})
+	errs.Go(func() error {
+		return writePNG(&imgB, outfile+"."+sanitized(urlB.Host))
+	})
+	if err := errs.Wait(); err != nil {
+		return fmt.Errorf("writePNG(...): %w", errs.Wait())
+	}
+	fmt.Printf("wrote diff to %s\n\n", out)
+	return fmt.Errorf("%s != %s", urlA, urlB)
+}
+
+// captureScreenshot runs a series of browser actions and takes a screenshot
+// of the resulting webpage in an instance of headless chrome.
+func captureScreenshot(ctx context.Context, u *url.URL, test *testcase) ([]byte, error) {
+	var buf []byte
+	ctx, cancel := chromedp.NewContext(ctx)
+	defer cancel()
+	ctx, cancel = context.WithTimeout(ctx, time.Minute)
+	defer cancel()
+	tasks := chromedp.Tasks{
+		chromedp.EmulateViewport(int64(test.viewportWidth), int64(test.viewportHeight)),
+		chromedp.Navigate(u.String()),
+		waitForEvent("networkIdle"),
+		test.tasks,
+	}
+	switch test.screenshotType {
+	case fullScreenshot:
+		tasks = append(tasks, chromedp.FullScreenshot(&buf, 100))
+	case viewportScreenshot:
+		tasks = append(tasks, chromedp.CaptureScreenshot(&buf))
+	case elementScreenshot:
+		tasks = append(tasks, chromedp.Screenshot(test.screenshotElement, &buf))
+	}
+	if err := chromedp.Run(ctx, tasks); err != nil {
+		return nil, fmt.Errorf("chromedp.Run(...): %w", err)
+	}
+	return buf, nil
+}
+
+// writePNG writes image data to a png file.
+func writePNG(i *image.Image, filename string) error {
+	f, err := os.Create(filename + ".png")
+	if err != nil {
+		return fmt.Errorf("os.Create(%q): %w", filename+".png", err)
+	}
+	err = png.Encode(f, *i)
+	if err != nil {
+		// Ignore f.Close() error, since png.Encode returned an error.
+		_ = f.Close()
+		return fmt.Errorf("png.Encode(...): %w", err)
+	}
+	if err := f.Close(); err != nil {
+		return fmt.Errorf("f.Close(): %w", err)
+	}
+	return nil
+}
+
+// sanitized transforms text into a string suitable for use in a
+// filename part.
+func sanitized(text string) string {
+	return sanitize.ReplaceAllString(text, "-")
+}
+
+// waitForEvent waits for browser lifecycle events. This is useful for
+// ensuring the page is fully loaded before capturing screenshots.
+func waitForEvent(eventName string) chromedp.ActionFunc {
+	return func(ctx context.Context) error {
+		ch := make(chan struct{})
+		cctx, cancel := context.WithCancel(ctx)
+		chromedp.ListenTarget(cctx, func(ev interface{}) {
+			switch e := ev.(type) {
+			case *page.EventLifecycleEvent:
+				if e.Name == eventName {
+					cancel()
+					close(ch)
+				}
+			}
+		})
+		select {
+		case <-ch:
+			return nil
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+}
diff --git a/internal/screentest/screentest_test.go b/internal/screentest/screentest_test.go
new file mode 100644
index 0000000..f0c90e3
--- /dev/null
+++ b/internal/screentest/screentest_test.go
@@ -0,0 +1,181 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package screentest
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"github.com/chromedp/chromedp"
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestReadTests(t *testing.T) {
+	type args struct {
+		filename string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    interface{}
+		wantErr bool
+	}{
+		{
+			name: "test",
+			args: args{
+				filename: "testdata/readtests.txt",
+			},
+			want: []*testcase{
+				{
+					name:           "go.dev homepage",
+					originA:        "https://go.dev",
+					originB:        "http://localhost:6060/go.dev",
+					viewportWidth:  1536,
+					viewportHeight: 960,
+					screenshotType: fullScreenshot,
+					pathame:        "/",
+				},
+				{
+					name:           "go.dev homepage 540x1080",
+					originA:        "https://go.dev",
+					originB:        "http://localhost:6060/go.dev",
+					viewportWidth:  540,
+					viewportHeight: 1080,
+					screenshotType: fullScreenshot,
+					pathame:        "/",
+				},
+				{
+					name:           "about page",
+					originA:        "https://go.dev",
+					originB:        "http://localhost:6060/go.dev",
+					screenshotType: fullScreenshot,
+					viewportWidth:  1536,
+					viewportHeight: 960,
+					pathame:        "/about",
+				},
+				{
+					name:              "pkg.go.dev homepage .go-Carousel",
+					originA:           "https://pkg.go.dev",
+					originB:           "https://beta.pkg.go.dev",
+					screenshotType:    elementScreenshot,
+					screenshotElement: ".go-Carousel",
+					viewportWidth:     1536,
+					viewportHeight:    960,
+					pathame:           "/",
+					tasks: chromedp.Tasks{
+						chromedp.Click(".go-Carousel-dot"),
+					},
+				},
+				{
+					name:           "net package doc",
+					originA:        "https://pkg.go.dev",
+					originB:        "https://beta.pkg.go.dev",
+					screenshotType: viewportScreenshot,
+					viewportWidth:  1536,
+					viewportHeight: 960,
+					pathame:        "/net",
+					tasks: chromedp.Tasks{
+						chromedp.WaitReady(`[role="treeitem"][aria-expanded="true"]`),
+					},
+				},
+				{
+					name:           "net package doc 540x1080",
+					originA:        "https://pkg.go.dev",
+					originB:        "https://beta.pkg.go.dev",
+					screenshotType: viewportScreenshot,
+					viewportWidth:  540,
+					viewportHeight: 1080,
+					pathame:        "/net",
+					tasks: chromedp.Tasks{
+						chromedp.WaitReady(`[role="treeitem"][aria-expanded="true"]`),
+					},
+				},
+			},
+			wantErr: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := readTests(tt.args.filename)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("readTests() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(tt.want, got,
+				cmp.AllowUnexported(testcase{}),
+				cmp.Comparer(func(a, b chromedp.ActionFunc) bool {
+					return fmt.Sprint(a) == fmt.Sprint(b)
+				}),
+				cmp.Comparer(func(a, b chromedp.Selector) bool {
+					return fmt.Sprint(a) == fmt.Sprint(b)
+				}),
+			); diff != "" {
+				t.Errorf("readTests() mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestCheckHandler(t *testing.T) {
+	// Skip this test if Google Chrome is not installed.
+	_, err := exec.LookPath("google-chrome")
+	if err != nil {
+		t.Skip()
+	}
+	type args struct {
+		glob string
+	}
+	d, err := os.UserCacheDir()
+	if err != nil {
+		t.Errorf("os.UserCacheDir(): %v", err)
+	}
+	cache := filepath.Join(d, "screentest")
+	var tests = []struct {
+		name      string
+		args      args
+		wantErr   bool
+		wantFiles []string
+	}{
+		{
+			name: "pass",
+			args: args{
+				glob: "testdata/pass.txt",
+			},
+			wantErr: false,
+		},
+		{
+			name: "fail",
+			args: args{
+				glob: "testdata/fail.txt",
+			},
+			wantErr: true,
+			wantFiles: []string{
+				filepath.Join(cache, "fail-txt", "homepage.diff.png"),
+				filepath.Join(cache, "fail-txt", "homepage.go-dev.png"),
+				filepath.Join(cache, "fail-txt", "homepage.pkg-go-dev.png"),
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := CheckHandler(tt.args.glob); (err != nil) != tt.wantErr {
+				t.Fatalf("CheckHandler() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if tt.wantErr {
+				files, err := filepath.Glob(
+					filepath.Join(cache, sanitized(filepath.Base(tt.args.glob)), "*.png"))
+				if err != nil {
+					t.Fatal("error reading diff output")
+				}
+				if diff := cmp.Diff(tt.wantFiles, files); diff != "" {
+					t.Errorf("readTests() mismatch (-want +got):\n%s", diff)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/screentest/testdata/fail.txt b/internal/screentest/testdata/fail.txt
new file mode 100644
index 0000000..a64b335
--- /dev/null
+++ b/internal/screentest/testdata/fail.txt
@@ -0,0 +1,5 @@
+compare https://go.dev https://pkg.go.dev
+
+test homepage
+pathname /
+capture viewport
diff --git a/internal/screentest/testdata/pass.txt b/internal/screentest/testdata/pass.txt
new file mode 100644
index 0000000..9e933de
--- /dev/null
+++ b/internal/screentest/testdata/pass.txt
@@ -0,0 +1,5 @@
+compare https://go.dev https://go.dev
+
+test homepage
+pathname /
+capture viewport
diff --git a/internal/screentest/testdata/readtests.txt b/internal/screentest/testdata/readtests.txt
new file mode 100644
index 0000000..9b5ab02
--- /dev/null
+++ b/internal/screentest/testdata/readtests.txt
@@ -0,0 +1,24 @@
+windowsize 1536x960
+compare https://go.dev http://localhost:6060/go.dev
+
+test go.dev homepage
+pathname /
+capture fullscreen
+capture fullscreen 540x1080
+
+test about page
+pathname /about
+capture fullscreen
+
+compare https://pkg.go.dev https://beta.pkg.go.dev
+
+test pkg.go.dev homepage
+pathname /
+click .go-Carousel-dot
+capture element .go-Carousel
+
+test net package doc
+pathname /net
+wait [role="treeitem"][aria-expanded="true"]
+capture viewport
+capture viewport 540x1080