blob: 2a4a24dd7a17c82517bcded2be735b6d975561dd [file] [log] [blame]
// 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 template 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 {{.ComparisonURL}}
// 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 header KEY:VALUE to add headers to requests
//
// header Authorization: Bearer token
//
// Add the ::cache suffix to cache the images from an origin for subsequent
// test runs.
//
// compare https://go.dev::cache http://localhost:6060
//
// Use block URL ... to set URL patterns to block. Wildcards ('*') are allowed.
//
// block https://codecov.io/* https://travis-ci.com/*
//
// Use output DIRECTORY to set the output directory for diffs and cached images.
//
// output testdata/snapshots
//
// USE output BUCKETNAME for screentest to upload test output to a Cloud Storage bucket.
// The bucket must already exist prior to running the tests.
//
// output gs://bucket-name
//
// Values set with the keywords above apply to all testcases that follow. Values set with
// the keywords below reset each time the test keyword is used.
//
// 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 status CODE to set an expected HTTP status code. The default is 200.
//
// status 404
//
// 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
//
// Evaluate JavaScript snippets to hide elements or prepare the page in
// some other way.
//
// eval 'document.querySelector(".selector").remove();'
// eval 'window.scrollTo({top: 0});'
//
// Chain capture commands to create multiple testcases for a single page.
//
// windowsize 1536x960
// compare https://go.dev::cache http://localhost:6060
// output testdata/snapshots
//
// 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"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"text/template"
"time"
"cloud.google.com/go/storage"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"github.com/n7olkachev/imgdiff/pkg/imgdiff"
"golang.org/x/sync/errgroup"
"google.golang.org/api/iterator"
)
type CheckOptions struct {
// Update is true if cached screenshots should be udpated.
Update bool
// MaxConcurrency is the maximum number of testcases to run in parallel.
MaxConcurrency int
// Vars are accessible as values in the test script templates.
Vars map[string]string
// DebuggerURL is the URL to a chrome websocket debugger. If left empty
// screentest tries to find the Chrome exectuable on the system and starts
// a new instance.
DebuggerURL string
}
// CheckHandler runs the test scripts matched by glob. If any errors are
// encountered, CheckHandler returns an error listing the problems.
func CheckHandler(glob string, opts CheckOptions) error {
if opts.MaxConcurrency < 1 {
opts.MaxConcurrency = 1
}
now := time.Now()
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)
}
var cancel context.CancelFunc
if opts.DebuggerURL != "" {
ctx, cancel = chromedp.NewRemoteAllocator(ctx, opts.DebuggerURL)
} else {
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, opts.Vars)
if err != nil {
return fmt.Errorf("readTestdata(%q): %w", file, err)
}
if len(tests) == 0 {
return fmt.Errorf("no tests found in %q", file)
}
if err := cleanOutput(ctx, tests); err != nil {
return fmt.Errorf("cleanOutput(...): %w", err)
}
ctx, cancel = chromedp.NewContext(ctx, chromedp.WithLogf(log.Printf))
defer cancel()
var hdr bool
runConcurrently(len(tests), opts.MaxConcurrency, func(i int) {
tc := tests[i]
if err := tc.run(ctx, opts.Update); err != nil {
if !hdr {
fmt.Fprintf(&buf, "%s\n\n", file)
hdr = true
}
fmt.Fprintf(&buf, "%v\n", err)
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 {
return errors.New(buf.String())
}
return nil
}
type TestOpts struct {
// Update is true if cached screenshots should be udpated.
Update bool
// Parallel runs t.Parallel for each testcase.
Parallel bool
// Vars are accessible as values in the test script templates.
Vars map[string]string
}
// TestHandler runs the test script files matched by glob.
func TestHandler(t *testing.T, glob string, opts TestOpts) {
ctx := context.Background()
files, err := filepath.Glob(glob)
if err != nil {
t.Fatal(err)
}
if len(files) == 0 {
t.Fatal(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, opts.Vars)
if err != nil {
t.Fatal(err)
}
if err := cleanOutput(ctx, tests); err != nil {
t.Fatal(err)
}
ctx, cancel = chromedp.NewContext(ctx, chromedp.WithLogf(t.Logf))
defer cancel()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if opts.Parallel {
t.Parallel()
}
if err := tc.run(ctx, opts.Update); err != nil {
t.Fatal(err)
}
})
}
}
}
// cleanOutput clears the output locations of images not cached
// as part of a testcase, including diff output from previous test
// runs and obsolete screenshots. It ensures local directories exist
// for test output. GCS buckets must already exist prior to test run.
func cleanOutput(ctx context.Context, tests []*testcase) error {
keepFiles := make(map[string]bool)
bkts := make(map[string]bool)
dirs := make(map[string]bool)
// The extensions of files that are safe to delete
safeExts := map[string]bool{
"a.png": true,
"b.png": true,
"diff.png": true,
}
for _, t := range tests {
if t.cacheA {
keepFiles[t.outImgA] = true
}
if t.cacheB {
keepFiles[t.outImgB] = true
}
if t.gcsBucket {
bkt, _ := gcsParts(t.outDiff)
bkts[bkt] = true
} else {
dirs[filepath.Dir(t.outDiff)] = true
}
}
if err := cleanBkts(ctx, bkts, keepFiles, safeExts); err != nil {
return fmt.Errorf("cleanBkts(...): %w", err)
}
if err := cleanDirs(dirs, keepFiles, safeExts); err != nil {
return fmt.Errorf("cleanDirs(...): %w", err)
}
return nil
}
// cleanBkts clears all the GCS buckets in bkts of all objects not included
// in the set of keepFiles. Buckets that do not exist will cause an error.
func cleanBkts(ctx context.Context, bkts, keepFiles, safeExts map[string]bool) error {
if len(bkts) == 0 {
return nil
}
client, err := storage.NewClient(ctx)
if err != nil {
return fmt.Errorf("storage.NewClient(ctx): %w", err)
}
defer client.Close()
for bkt := range bkts {
it := client.Bucket(bkt).Objects(ctx, nil)
for {
attrs, err := it.Next()
if err == iterator.Done {
break
}
if err != nil {
return fmt.Errorf("it.Next(): %w", err)
}
filename := "gs://" + attrs.Bucket + "/" + attrs.Name
if !keepFiles[filename] && safeExts[ext(filename)] {
if err := client.Bucket(attrs.Bucket).Object(attrs.Name).Delete(ctx); err != nil &&
!errors.Is(err, storage.ErrObjectNotExist) {
return fmt.Errorf("Object(%q).Delete: %v", attrs.Name, err)
}
}
}
}
return client.Close()
}
// cleanBkts ensures the set of directories in dirs exists and
// clears dirs of all files not included in the set of keepFiles.
func cleanDirs(dirs, keepFiles, safeExts map[string]bool) error {
for dir := range dirs {
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("os.MkdirAll(%q): %w", dir, err)
}
}
for dir := range dirs {
files, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("os.ReadDir(%q): %w", dir, err)
}
for _, f := range files {
filename := dir + "/" + f.Name()
if !keepFiles[filename] && safeExts[ext(filename)] {
if err := os.Remove(filename); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("os.Remove(%q): %w", filename, err)
}
}
}
}
return nil
}
func ext(filename string) string {
// If the filename has multiple dots use the first one as
// the split for the extension.
if strings.Count(filename, ".") > 1 {
base := filepath.Base(filename)
parts := strings.SplitN(base, ".", 2)
return parts[1]
}
return filepath.Ext(filename)
}
const (
browserWidth = 1536
browserHeight = 960
cacheSuffix = "::cache"
gcsScheme = "gs://"
)
var sanitize = regexp.MustCompile("[.*<>?`'|/\\: ]")
type screenshotType int
const (
fullScreenshot screenshotType = iota
viewportScreenshot
elementScreenshot
)
type testcase struct {
name string
tasks chromedp.Tasks
urlA, urlB string
headers map[string]interface{}
status int
cacheA, cacheB bool
gcsBucket bool
outImgA, outImgB, outDiff string
viewportWidth int
viewportHeight int
screenshotType screenshotType
screenshotElement string
blockedURLs []string
output bytes.Buffer
}
func (t *testcase) String() string {
return t.name
}
// readTests parses the testcases from a text file.
func readTests(file string, vars map[string]string) ([]*testcase, error) {
tmpl, err := template.ParseFiles(file)
if err != nil {
return nil, fmt.Errorf("template.ParseFiles(%q): %w", file, err)
}
var tmplout bytes.Buffer
if err := tmpl.Execute(&tmplout, vars); err != nil {
return nil, fmt.Errorf("tmpl.Execute(...): %w", err)
}
var tests []*testcase
var (
testName, pathname string
tasks chromedp.Tasks
originA, originB string
headers map[string]interface{}
status int = http.StatusOK
cacheA, cacheB bool
gcsBucket bool
width, height int
lineNo int
blockedURLs []string
)
cache, err := os.UserCacheDir()
if err != nil {
return nil, fmt.Errorf("os.UserCacheDir(): %w", err)
}
dir := filepath.Join(cache, "screentest")
out := outDir(dir, file)
scan := bufio.NewScanner(&tmplout)
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
status = http.StatusOK
case "COMPARE":
origins := strings.Split(args, " ")
originA, originB = origins[0], origins[1]
cacheA, cacheB = false, false
if headers != nil {
headers = make(map[string]interface{})
}
if strings.HasSuffix(originA, cacheSuffix) {
originA = strings.TrimSuffix(originA, cacheSuffix)
cacheA = true
}
if strings.HasSuffix(originB, cacheSuffix) {
originB = strings.TrimSuffix(originB, cacheSuffix)
cacheB = true
}
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 "HEADER":
if headers == nil {
headers = make(map[string]interface{})
}
parts := strings.SplitN(args, ":", 2)
if len(parts) != 2 {
log.Fatalf("invalid header %s on line %d", args, lineNo)
}
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
case "STATUS":
status, err = strconv.Atoi(args)
if err != nil {
return nil, fmt.Errorf("strconv.Atoi(%q): %w", args, err)
}
case "OUTPUT":
if strings.HasPrefix(args, gcsScheme) {
gcsBucket = true
}
out = outDir(args, "")
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[1:]
}
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 "EVAL":
tasks = append(tasks, chromedp.Evaluate(args, nil))
case "BLOCK":
blockedURLs = append(blockedURLs, strings.Fields(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)
}
urlA, err := url.Parse(originA + pathname)
if err != nil {
return nil, fmt.Errorf("url.Parse(%q): %w", originA+pathname, err)
}
urlB, err := url.Parse(originB + pathname)
if err != nil {
return nil, fmt.Errorf("url.Parse(%q): %w", originB+pathname, err)
}
test := &testcase{
name: testName,
tasks: tasks,
urlA: urlA.String(),
urlB: urlB.String(),
headers: headers,
status: status,
blockedURLs: blockedURLs,
// Default to viewportScreenshot
screenshotType: viewportScreenshot,
viewportWidth: width,
viewportHeight: height,
cacheA: cacheA,
cacheB: cacheB,
gcsBucket: gcsBucket,
}
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 += fmt.Sprintf(" %dx%d", w, h)
test.viewportWidth = w
test.viewportHeight = h
}
case "ELEMENT":
test.name += fmt.Sprintf(" %s", args)
test.screenshotType = elementScreenshot
test.screenshotElement = args
}
outfile := filepath.Join(out, sanitized(test.name))
if gcsBucket {
outfile = out + "/" + sanitized(test.name)
}
test.outImgA = outfile + ".a.png"
test.outImgB = outfile + ".b.png"
test.outDiff = outfile + ".diff.png"
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
}
// outDir gets a diff output directory for a given testfile.
// If dir points to a GCS bucket or testfile is empty it just
// returns dir.
func outDir(dir, testfile string) string {
if strings.HasPrefix(dir, gcsScheme) {
return dir
}
if testfile != "" {
dir = filepath.Join(dir, sanitized(filepath.Base(testfile)))
}
return filepath.Clean(dir)
}
// 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
}
// run generates screenshots for a given test case and a diff if the
// screenshots do not match.
func (tc *testcase) run(ctx context.Context, update bool) (err error) {
now := time.Now()
fmt.Fprintf(&tc.output, "test %s ", tc.name)
var screenA, screenB *image.Image
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
screenA, err = tc.screenshot(ctx, tc.urlA, tc.outImgA, tc.cacheA, update)
if err != nil {
return fmt.Errorf("screenshot(ctx, %q, %q, %q, %v): %w", tc, tc.urlA, tc.outImgA, tc.cacheA, err)
}
return nil
})
g.Go(func() error {
screenB, err = tc.screenshot(ctx, tc.urlB, tc.outImgB, tc.cacheB, update)
if err != nil {
return fmt.Errorf("screenshot(ctx, %q, %q, %q, %v): %w", tc, tc.urlB, tc.outImgB, tc.cacheB, err)
}
return nil
})
if err := g.Wait(); err != nil {
fmt.Fprint(&tc.output, "\n", err)
return err
}
result := imgdiff.Diff(*screenA, *screenB, &imgdiff.Options{
Threshold: 0.1,
DiffImage: true,
})
since := time.Since(now).Truncate(time.Millisecond)
if result.Equal {
fmt.Fprintf(&tc.output, "(%s)\n", since)
return nil
}
fmt.Fprintf(&tc.output, "(%s)\nFAIL %s != %s\n", since, tc.urlA, tc.urlB)
g = &errgroup.Group{}
g.Go(func() error {
return writePNG(&result.Image, tc.outDiff)
})
// Only write screenshots if they haven't already been written to the cache.
if !tc.cacheA {
g.Go(func() error {
return writePNG(screenA, tc.outImgA)
})
}
if !tc.cacheB {
g.Go(func() error {
return writePNG(screenB, tc.outImgB)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("writePNG(...): %w", err)
}
fmt.Fprintf(&tc.output, "wrote diff to %s\n", tc.outDiff)
return fmt.Errorf("%s != %s", tc.urlA, tc.urlB)
}
// screenshot gets a screenshot for a testcase url. When cache is true it will
// attempt to read the screenshot from a cache or capture a new screenshot
// and write it to the cache if it does not exist.
func (tc *testcase) screenshot(ctx context.Context, url, file string,
cache, update bool,
) (_ *image.Image, err error) {
var data []byte
// If cache is enabled, try to read the file from the cache.
if cache && tc.gcsBucket {
client, err := storage.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("storage.NewClient(err): %w", err)
}
defer client.Close()
bkt, obj := gcsParts(file)
r, err := client.Bucket(bkt).Object(obj).NewReader(ctx)
if err != nil && !errors.Is(err, storage.ErrObjectNotExist) {
return nil, fmt.Errorf("object.NewReader(ctx): %w", err)
} else if err == nil {
defer r.Close()
data, err = ioutil.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("ioutil.ReadAll(...): %w", err)
}
}
} else if cache {
data, err = os.ReadFile(file)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("os.ReadFile(...): %w", err)
}
}
// If cache is false, an update is requested, or this is the first test run
// we capture a new screenshot from a live URL.
if !cache || update || data == nil {
update = true
data, err = tc.captureScreenshot(ctx, url)
if err != nil {
return nil, fmt.Errorf("captureScreenshot(ctx, %q, %q): %w", url, tc, err)
}
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("image.Decode(...): %w", err)
}
// Write to the cache.
if cache && update {
if err := writePNG(&img, file); err != nil {
return nil, fmt.Errorf("os.WriteFile(...): %w", err)
}
fmt.Fprintf(&tc.output, "updated %s\n", file)
}
return &img, nil
}
type response struct {
Status int
}
// captureScreenshot runs a series of browser actions and takes a screenshot
// of the resulting webpage in an instance of headless chrome.
func (tc *testcase) captureScreenshot(ctx context.Context, url string) ([]byte, error) {
var buf []byte
ctx, cancel := chromedp.NewContext(ctx)
defer cancel()
ctx, cancel = context.WithTimeout(ctx, time.Minute)
defer cancel()
var tasks chromedp.Tasks
if tc.headers != nil {
tasks = append(tasks, network.SetExtraHTTPHeaders(tc.headers))
}
if tc.blockedURLs != nil {
tasks = append(tasks, network.SetBlockedURLS(tc.blockedURLs))
}
var res response
tasks = append(tasks,
getResponse(url, &res),
chromedp.EmulateViewport(int64(tc.viewportWidth), int64(tc.viewportHeight)),
chromedp.Navigate(url),
waitForEvent("networkIdle"),
reduceMotion(),
checkResponse(tc, &res),
tc.tasks,
)
switch tc.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(tc.screenshotElement, &buf))
}
if err := chromedp.Run(ctx, tasks); err != nil {
return nil, fmt.Errorf("chromedp.Run(...): %w", err)
}
return buf, nil
}
func reduceMotion() chromedp.Action {
css := `*, ::before, ::after {
animation-delay: -1ms !important;
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
background-attachment: initial !important;
caret-color: transparent;
scroll-behavior: auto !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}`
script := `
(() => {
const style = document.createElement('style');
style.type = 'text/css';
style.appendChild(document.createTextNode(` + "`" + css + "`" + `));
document.head.appendChild(style);
})()
`
return chromedp.Evaluate(script, nil)
}
// writePNG writes image data to a png file.
func writePNG(i *image.Image, filename string) (err error) {
var f io.WriteCloser
if strings.HasPrefix(filename, gcsScheme) {
ctx := context.Background()
client, err := storage.NewClient(ctx)
if err != nil {
return fmt.Errorf("storage.NewClient(ctx): %w", err)
}
defer client.Close()
bkt, obj := gcsParts(filename)
f = client.Bucket(bkt).Object(obj).NewWriter(ctx)
} else {
f, err = os.Create(filename)
if err != nil {
return fmt.Errorf("os.Create(%q): %w", filename, 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, "-")
}
// gcsParts splits a Cloud Storage filename into bucket name and
// object name parts.
func gcsParts(filename string) (bucket, object string) {
filename = strings.TrimPrefix(filename, gcsScheme)
n := strings.Index(filename, "/")
bucket = filename[:n]
object = filename[n+1:]
return bucket, object
}
// 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()
}
}
}
func getResponse(u string, res *response) chromedp.ActionFunc {
return func(ctx context.Context) error {
chromedp.ListenTarget(ctx, func(ev interface{}) {
// URL fragments are dropped in request targets so we must strip the fragment
// from the URL to make a comparison.
_u, _ := url.Parse(u)
_u.Fragment = ""
switch e := ev.(type) {
case *network.EventResponseReceived:
if e.Response.URL == _u.String() {
res.Status = int(e.Response.Status)
}
// Capture the status from a redirected response.
case *network.EventRequestWillBeSent:
if e.RedirectResponse != nil && e.RedirectResponse.URL == _u.String() {
res.Status = int(e.RedirectResponse.Status)
}
}
})
return nil
}
}
func checkResponse(tc *testcase, res *response) chromedp.ActionFunc {
return func(context.Context) error {
if res.Status != tc.status {
fmt.Fprintf(&tc.output, "\nFAIL http status mismatch: got %d; want %d", res.Status, tc.status)
return fmt.Errorf("bad status: %d", res.Status)
}
return nil
}
}
// 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{}{}
}
}