aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRuss Cox <rsc@golang.org>2021-05-18 12:17:17 -0400
committerRuss Cox <rsc@golang.org>2021-06-15 02:12:39 +0000
commite5882a3721863c3bb2a45547ee0897f28e8c339c (patch)
tree6ecffcb657a30685b1f1d6668130f4760b7c8408
parentde60b61b0ea7867bb1ded3768939cb8cf43f0e84 (diff)
downloadgo-x-website-e5882a3721863c3bb2a45547ee0897f28e8c339c.tar.xz
internal/webtest: add package for testing web servers
This package provides a way to write simple script-based tests of web server behaviors, instead of reinventing the logic in every test, as we too often do. See the doc comment in webtest.go for more details. Change-Id: Ie1af4f1def488a7520dce46c242643aec15a2fcf Reviewed-on: https://go-review.googlesource.com/c/website/+/321074 Trust: Russ Cox <rsc@golang.org> Run-TryBot: Russ Cox <rsc@golang.org> TryBot-Result: Go Bot <gobot@golang.org> Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
-rw-r--r--internal/webtest/testdata/echo.txt14
-rw-r--r--internal/webtest/testdata/fs.txt31
-rw-r--r--internal/webtest/testdata/hello.html2
-rw-r--r--internal/webtest/webtest.go678
-rw-r--r--internal/webtest/webtest_test.go85
5 files changed, 810 insertions, 0 deletions
diff --git a/internal/webtest/testdata/echo.txt b/internal/webtest/testdata/echo.txt
new file mode 100644
index 00000000..c97402e6
--- /dev/null
+++ b/internal/webtest/testdata/echo.txt
@@ -0,0 +1,14 @@
+GET http://example.com/echo
+body contains no query
+
+GET http://example.com/echo?q=x+y
+body contains "q": ["x y"]
+
+POST http://example.com/echo
+postbody q=x+y
+body contains "q": ["x y"]
+
+POST http://example.com/echo
+postquery
+ q=x y
+body contains "q": ["x y"]
diff --git a/internal/webtest/testdata/fs.txt b/internal/webtest/testdata/fs.txt
new file mode 100644
index 00000000..13d61a92
--- /dev/null
+++ b/internal/webtest/testdata/fs.txt
@@ -0,0 +1,31 @@
+GET /hello.html
+body contains <!DOCTYPE html>
+code == 200
+code != 404
+header content-type == text/html; charset=utf-8
+body ~ hello, +world
+body != hello, world
+body ==
+ <!DOCTYPE html>
+ hello, world
+body !~ \A\z
+
+HEAD https://example.com/hello.html
+code == 200
+body !~ .
+
+# check failed test
+GET /hello.html
+hint header content-type = "text/html; charset=utf-8", want "text/html"
+header content-type == text/html
+
+# check failed test
+GET /hello.html
+hint body matches `.` (but should not)
+body !~ .
+
+# check failed test
+GET /hello.html
+hint body contains `hello` (but should not)
+body !contains hello
+
diff --git a/internal/webtest/testdata/hello.html b/internal/webtest/testdata/hello.html
new file mode 100644
index 00000000..32855789
--- /dev/null
+++ b/internal/webtest/testdata/hello.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+hello, world
diff --git a/internal/webtest/webtest.go b/internal/webtest/webtest.go
new file mode 100644
index 00000000..c16c4bb0
--- /dev/null
+++ b/internal/webtest/webtest.go
@@ -0,0 +1,678 @@
+// 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.
+
+//go:build go1.16
+// +build go1.16
+
+// Package webtest implements script-based testing for web servers.
+//
+// The scripts, described below, can be run against http.Handler
+// implementations or against running servers. Testing against an
+// http.Handler makes it easier to test handlers serving multiple sites
+// as well as scheme-based features like redirecting to HTTPS.
+// Testing against a running server provides a more complete end-to-end test.
+//
+// The test functions TestHandler and TestServer take a *testing.T
+// and a glob pattern, which must match at least one file.
+// They create a subtest of the top-level test for each script.
+// Within each per-script subtest, they create a per-case subtest
+// for each case in the script, making it easy to run selected cases.
+//
+// The functions CheckHandler and CheckServer are similar but do
+// not require a *testing.T, making them suitable for use in other contexts.
+// They run the entire script and return a multiline error summarizing
+// any problems.
+//
+// Scripts
+//
+// A script is a text file containing a sequence of cases, separated by blank lines.
+// A case is a sequence of lines describing a request, along with checks to be
+// applied to the response. For example, here is a trivial script:
+//
+// GET /
+// body contains Go is an open source programming language
+//
+// This script has a single case. The first line describes the request.
+// The second line describes a single check to be applied to the response.
+// In this case, the request is a GET of the URL /, and the response body
+// must contain the text “Go is an open source programming language”.
+//
+// Requests
+//
+// Each case begins with a line starting with GET, HEAD, or POST.
+// The argument (the remainder of the line) is the URL to be used in the request.
+// Following this line, the request can be further customized using
+// lines of the form
+//
+// <verb> <text>
+//
+// where the verb is a single space-separated word and the text is arbitrary text
+// to the end of the line, or multiline text (described below).
+//
+// The possible values for <verb> are as follows.
+//
+// The verb “hint” specifies text to be printed if the test case fails, as a
+// hint about what might be wrong.
+//
+// The verbs “postbody”, “postquery”, and “posttype” customize a POST request.
+//
+// For example:
+//
+// POST /api
+// posttype application/json
+// postbody {"go": true}
+//
+// This describes a POST request with a posted Content-Type of “application/json”
+// and a body “{"go": true}”.
+//
+// The “postquery” verb specifies a post body in the form of a sequence of
+// key-value pairs, query-encoded and concatenated automatically as a
+// convenience. Using “postquery” also sets the default posted Content-Type
+// to “application/x-www-form-urlencoded”.
+//
+// For example:
+//
+// POST /api
+// postquery
+// x=hello world
+// y=Go & You
+//
+// This stanza sends a request with post body “x=hello+world&y=Go+%26+You”.
+// (The multiline syntax is described in detail below.)
+//
+// Checks
+//
+// By default, a stanza like the ones above checks only that the request
+// succeeds in returning a response with HTTP status code 200 (OK).
+// Additional checks are specified by more lines of the form
+//
+// <value> [<key>] <op> <text>
+//
+// In the example above, <value> is “body”, there is no <key>,
+// <op> is “contains”, and <text> is “Go is an open source programming language”.
+// Whether there is a <key> depends on the <value>; “body” does not have one.
+//
+// The possible values for <value> are:
+//
+// body - the full response body
+// code - the HTTP status code
+// header <key> - the value in the header line with the given key
+// redirect - the target of a redirect, as found in the Location header
+// trimbody - the response body, trimmed
+//
+// If a case contains no check of “code”, then it defaults to checking that
+// the HTTP status code is 200, as described above, with one exception:
+// if the case contains a check of “redirect”, then the code is required to
+// be a 30x code.
+//
+// The “trimbody” value is the body with all runs of spaces and tabs
+// reduced to single spaces, leading and trailing spaces removed on
+// each line, and blank lines removed.
+//
+// The possible operators for <op> are:
+//
+// == - the value must be equal to the text
+// != - the value must not be equal to the text
+// ~ - the value must match the text interprted as a regular expression
+// !~ - the value must not match the text interprted as a regular expression
+// contains - the value must contain the text as a substring
+// !contains - the value must not contain the text as a substring
+//
+// For example:
+//
+// GET /change/75944e2e3a63
+// hint no change redirect - hg to git mapping not registered?
+// code == 302
+// redirect contains bdb10cf
+// body contains bdb10cf
+// body !contains UA-
+//
+// GET /pkg/net/http/httptrace/
+// body ~ Got1xxResponse.*// Go 1\.11
+// body ~ GotFirstResponseByte func\(\)\s*$
+//
+// Multiline Texts
+//
+// The <text> in a request or check line can take a multiline form,
+// by omitting it from the original line and then specifying the text
+// as one or more following lines, each indented by a single tab.
+// The text is taken to be the sequence of indented lines, including
+// the final newline, but with the leading tab removed from each.
+//
+// The “postquery” example above showed the multiline syntax.
+// Another common use is for multiline “body” checks. For example:
+//
+// GET /hello
+// body ==
+// <!DOCTYPE html>
+// hello, world
+//
+package webtest
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "unicode/utf8"
+)
+
+// CheckHandler runs the test script files matched by glob
+// against the handler h. If any errors are encountered,
+// CheckHandler returns an error listing the problems.
+func CheckHandler(glob string, h http.Handler) error {
+ return check(glob, func(c *case_) error { return c.runHandler(h) })
+}
+
+// CheckServer runs the test script files matched by glob
+// against the server at addr, which may be either the network address
+// of an HTTP proxy (host:port) or a base URL to be inserted at the start
+// of each path-only URL in the script. If any errors are encountered,
+// CheckServer returns an error listing the problems.
+func CheckServer(glob string, addr string) error {
+ return check(glob, func(c *case_) error { return c.runServer(addr) })
+}
+
+func check(glob string, do func(*case_) error) error {
+ files, err := filepath.Glob(glob)
+ if err != nil {
+ return err
+ }
+ if len(files) == 0 {
+ return fmt.Errorf("no files match %#q", glob)
+ }
+ var buf bytes.Buffer
+ for _, file := range files {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ fmt.Fprintf(&buf, "# %s\n%v\n", file, err)
+ continue
+ }
+ script, err := parseScript(file, string(data))
+ if err != nil {
+ fmt.Fprintf(&buf, "# %s\n%v\n", file, err)
+ continue
+ }
+ hdr := false
+ for _, c := range script.cases {
+ if err := do(c); err != nil {
+ if !hdr {
+ fmt.Fprintf(&buf, "# %s\n", file)
+ hdr = true
+ }
+ fmt.Fprintf(&buf, "## %s %s\n", c.method, c.url)
+ 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
+// against the handler h.
+func TestHandler(t *testing.T, glob string, h http.Handler) {
+ test(t, glob, func(c *case_) error { return c.runHandler(h) })
+}
+
+// TestServer runs the test script files matched by glob
+// against the server at addr, which may be either the network address
+// of an HTTP proxy (host:port) or a base URL to be inserted at the start
+// of each path-only URL in the script.
+func TestServer(t *testing.T, glob, addr string) {
+ test(t, glob, func(c *case_) error { return c.runServer(addr) })
+}
+
+func test(t *testing.T, glob string, do func(*case_) error) {
+ files, err := filepath.Glob(glob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(files) == 0 {
+ t.Fatalf("no files match %#q", glob)
+ }
+ for _, file := range files {
+ t.Run(filepath.Base(file), func(t *testing.T) {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ script, err := parseScript(file, string(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range script.cases {
+ t.Run(c.method+"/"+strings.TrimPrefix(c.url, "/"), func(t *testing.T) {
+ if err := do(c); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ })
+ }
+}
+
+// A script is a parsed test script.
+type script struct {
+ cases []*case_
+}
+
+// A case_ is a single test case (GET/HEAD/POST) in a script.
+type case_ struct {
+ file string
+ line int
+ method string
+ url string
+ postbody string
+ postquery string
+ posttype string
+ hint string
+ checks []*cmpCheck
+}
+
+// A cmp is a single comparison (check) made against a test case.
+type cmpCheck struct {
+ file string
+ line int
+ what string
+ whatArg string
+ op string
+ want string
+ wantRE *regexp.Regexp
+}
+
+// runHandler runs a test case against the handler h.
+func (c *case_) runHandler(h http.Handler) error {
+ w := httptest.NewRecorder()
+ r, err := c.newRequest(c.url)
+ if err != nil {
+ return err
+ }
+ h.ServeHTTP(w, r)
+ return c.check(w.Result(), w.Body.String())
+}
+
+// runServer runs a test case against the server at address addr.
+func (c *case_) runServer(addr string) error {
+ baseURL := ""
+ if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
+ // addr is a base URL
+ if !strings.HasSuffix(addr, "/") {
+ addr += "/"
+ }
+ baseURL = addr
+ } else {
+ // addr is an HTTP proxy
+ baseURL = "http://" + addr + "/"
+ }
+
+ // Build full URL for request.
+ u := c.url
+ if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
+ u = strings.TrimSuffix(baseURL, "/")
+ if !strings.HasPrefix(c.url, "/") {
+ u += "/"
+ }
+ u += c.url
+ }
+ req, err := c.newRequest(u)
+
+ if err != nil {
+ return fmt.Errorf("%s:%d: %s %s: %s", c.file, c.line, c.method, c.url, err)
+ }
+ tr := &http.Transport{}
+ if !strings.HasPrefix(u, baseURL) {
+ // If u does not begin with baseURL, then we're in the proxy case
+ // and we try to tunnel the network activity through the proxy's address.
+ proxyURL, err := url.Parse(baseURL)
+ if err != nil {
+ return fmt.Errorf("invalid addr: %v", err)
+ }
+ tr.Proxy = func(*http.Request) (*url.URL, error) { return proxyURL, nil }
+ }
+ resp, err := tr.RoundTrip(req)
+ if err != nil {
+ return fmt.Errorf("%s:%d: %s %s: %s", c.file, c.line, c.method, c.url, err)
+ }
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return fmt.Errorf("%s:%d: %s %s: reading body: %s", c.file, c.line, c.method, c.url, err)
+ }
+ return c.check(resp, string(body))
+}
+
+// newRequest creates a new request for the case c,
+// using the URL u.
+func (c *case_) newRequest(u string) (*http.Request, error) {
+ body := c.requestBody()
+ r, err := http.NewRequest(c.method, u, body)
+ if err != nil {
+ return nil, err
+ }
+ typ := c.posttype
+ if body != nil && typ == "" {
+ typ = "application/x-www-form-urlencoded"
+ }
+ if typ != "" {
+ r.Header.Set("Content-Type", typ)
+ }
+ return r, nil
+}
+
+// requestBody returns the body for the case's request.
+func (c *case_) requestBody() io.Reader {
+ if c.postbody == "" {
+ return nil
+ }
+ return strings.NewReader(c.postbody)
+}
+
+// check checks the response against the comparisons for the case.
+func (c *case_) check(resp *http.Response, body string) error {
+ var msg bytes.Buffer
+ for _, chk := range c.checks {
+ what := chk.what
+ if chk.whatArg != "" {
+ what += " " + chk.whatArg
+ }
+ var value string
+ switch chk.what {
+ default:
+ value = "unknown what: " + chk.what
+ case "body":
+ value = body
+ case "trimbody":
+ value = trim(body)
+ case "code":
+ value = fmt.Sprint(resp.StatusCode)
+ case "header":
+ value = resp.Header.Get(chk.whatArg)
+ case "redirect":
+ if resp.StatusCode/10 == 30 {
+ value = resp.Header.Get("Location")
+ }
+ }
+
+ switch chk.op {
+ default:
+ fmt.Fprintf(&msg, "%s:%d: unknown operator %s\n", chk.file, chk.line, chk.op)
+ case "==":
+ if value != chk.want {
+ fmt.Fprintf(&msg, "%s:%d: %s = %q, want %q\n", chk.file, chk.line, what, value, chk.want)
+ }
+ case "!=":
+ if value == chk.want {
+ fmt.Fprintf(&msg, "%s:%d: %s == %q (but want !=)\n", chk.file, chk.line, what, value)
+ }
+ case "~":
+ if !chk.wantRE.MatchString(value) {
+ fmt.Fprintf(&msg, "%s:%d: %s does not match %#q (but should)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value))
+ }
+ case "!~":
+ if chk.wantRE.MatchString(value) {
+ fmt.Fprintf(&msg, "%s:%d: %s matches %#q (but should not)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value))
+ }
+ case "contains":
+ if !strings.Contains(value, chk.want) {
+ fmt.Fprintf(&msg, "%s:%d: %s does not contain %#q (but should)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value))
+ }
+ case "!contains":
+ if strings.Contains(value, chk.want) {
+ fmt.Fprintf(&msg, "%s:%d: %s contains %#q (but should not)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value))
+ }
+ }
+ }
+ if msg.Len() > 0 && c.hint != "" {
+ fmt.Fprintf(&msg, "hint: %s\n", indent(c.hint))
+ }
+
+ if msg.Len() > 0 {
+ return fmt.Errorf("%s:%d: %s %s\n%s", c.file, c.line, c.method, c.url, msg.String())
+ }
+ return nil
+}
+
+// trim returns a trimming of s, in which all runs of spaces and tabs have
+// been collapsed to a single space, leading and trailing spaces have been
+// removed from each line, and blank lines are removed entirely.
+func trim(s string) string {
+ s = regexp.MustCompile(`[ \t]+`).ReplaceAllString(s, " ")
+ s = regexp.MustCompile(`(?m)(^ | $)`).ReplaceAllString(s, "")
+ s = strings.TrimLeft(s, "\n")
+ s = regexp.MustCompile(`\n\n+`).ReplaceAllString(s, "\n")
+ return s
+}
+
+// indent indents text for formatting in a message.
+func indent(text string) string {
+ if text == "" {
+ return "(empty)"
+ }
+ if text == "\n" {
+ return "(blank line)"
+ }
+ text = strings.TrimRight(text, "\n")
+ if text == "" {
+ return "(blank lines)"
+ }
+ text = strings.ReplaceAll(text, "\n", "\n\t")
+ return text
+}
+
+// parseScript parses the test script in text.
+// Errors are reported as being from file, but file is not directly read.
+func parseScript(file, text string) (*script, error) {
+ var current struct {
+ Case *case_
+ Multiline *string
+ }
+ script := new(script)
+ lastLineWasBlank := true
+ lineno := 0
+ line := ""
+ errorf := func(format string, args ...interface{}) error {
+ if line != "" {
+ line = "\n" + line
+ }
+ return fmt.Errorf("%s:%d: %v%s", file, lineno, fmt.Sprintf(format, args...), line)
+ }
+ for text != "" {
+ lineno++
+ prevLine := line
+ line, text, _ = cut(text, "\n")
+ if strings.HasPrefix(line, "#") {
+ continue
+ }
+ line = strings.TrimRight(line, " \t")
+ if line == "" {
+ lastLineWasBlank = true
+ continue
+ }
+ what, args := splitOneField(line)
+
+ // Add indented line to current multiline check, or else it ends.
+ if what == "" {
+ // Line is indented.
+ if current.Multiline != nil {
+ lastLineWasBlank = false
+ *current.Multiline += args + "\n"
+ continue
+ }
+ return nil, errorf("unexpected indented line")
+ }
+
+ // Multiline text is over; must be present.
+ if current.Multiline != nil && *current.Multiline == "" {
+ lineno--
+ line = prevLine
+ return nil, errorf("missing multiline text")
+ }
+ current.Multiline = nil
+
+ // Look for start of new check.
+ switch what {
+ case "GET", "HEAD", "POST":
+ if !lastLineWasBlank {
+ return nil, errorf("missing blank line before start of case")
+ }
+ if args == "" {
+ return nil, errorf("missing %s URL", what)
+ }
+ cas := &case_{method: what, url: args, file: file, line: lineno}
+ script.cases = append(script.cases, cas)
+ current.Case = cas
+ lastLineWasBlank = false
+ continue
+ }
+
+ if lastLineWasBlank || current.Case == nil {
+ return nil, errorf("missing GET/HEAD/POST at start of check")
+ }
+
+ // Look for case metadata.
+ var targ *string
+ switch what {
+ case "postbody":
+ targ = &current.Case.postbody
+ case "postquery":
+ targ = &current.Case.postquery
+ case "posttype":
+ targ = &current.Case.posttype
+ case "hint":
+ targ = &current.Case.hint
+ }
+ if targ != nil {
+ if strings.HasPrefix(what, "post") && current.Case.method != "POST" {
+ return nil, errorf("need POST (not %v) for %v", current.Case.method, what)
+ }
+ if args != "" {
+ *targ = args
+ } else {
+ current.Multiline = targ
+ }
+ continue
+ }
+
+ // Start a comparison check.
+ chk := &cmpCheck{file: file, line: lineno, what: what}
+ current.Case.checks = append(current.Case.checks, chk)
+ switch what {
+ case "body", "code", "redirect":
+ // no WhatArg
+ case "header":
+ chk.whatArg, args = splitOneField(args)
+ if chk.whatArg == "" {
+ return nil, errorf("missing header name")
+ }
+ }
+
+ // Opcode, with optional leading "not"
+ chk.op, args = splitOneField(args)
+ switch chk.op {
+ case "==", "!=", "~", "!~", "contains", "!contains":
+ // ok
+ default:
+ return nil, errorf("unknown check operator %q", chk.op)
+ }
+
+ if args != "" {
+ chk.want = args
+ } else {
+ current.Multiline = &chk.want
+ }
+ }
+
+ // Finish each case.
+ // Compute POST body from POST query.
+ // Check that each regexp compiles, and insert "code equals 200"
+ // in each case that doesn't already have a code check.
+ for _, cas := range script.cases {
+ if cas.postquery != "" {
+ if cas.postbody != "" {
+ line = ""
+ lineno = cas.line
+ return nil, errorf("case has postbody and postquery")
+ }
+ for _, kv := range strings.Split(cas.postquery, "\n") {
+ kv = strings.TrimSpace(kv)
+ if kv == "" {
+ continue
+ }
+ k, v, ok := cut(kv, "=")
+ if !ok {
+ lineno = cas.line // close enough
+ line = kv
+ return nil, errorf("postquery has non key=value line")
+ }
+ if cas.postbody != "" {
+ cas.postbody += "&"
+ }
+ cas.postbody += url.QueryEscape(k) + "=" + url.QueryEscape(v)
+ }
+ }
+ sawCode := false
+ for _, chk := range cas.checks {
+ if chk.what == "code" || chk.what == "redirect" {
+ sawCode = true
+ }
+ if chk.op == "~" || chk.op == "!~" {
+ re, err := regexp.Compile(`(?m)` + chk.want)
+ if err != nil {
+ lineno = chk.line
+ line = chk.want
+ return nil, errorf("invalid regexp: %s", err)
+ }
+ chk.wantRE = re
+ }
+ }
+ if !sawCode {
+ line := cas.line
+ if len(cas.checks) > 0 {
+ line = cas.checks[0].line
+ }
+ chk := &cmpCheck{file: cas.file, line: line, what: "code", op: "==", want: "200"}
+ cas.checks = append(cas.checks, chk)
+ }
+ }
+ return script, nil
+}
+
+// cut returns the result of cutting s around the first instance of sep.
+func cut(s, sep string) (before, after string, ok bool) {
+ if i := strings.Index(s, sep); i >= 0 {
+ return s[:i], s[i+len(sep):], true
+ }
+ return s, "", false
+}
+
+// cutAny returns the result of cutting s around the first instance of
+// any code point from any.
+func cutAny(s, any string) (before, after string, ok bool) {
+ if i := strings.IndexAny(s, any); i >= 0 {
+ _, size := utf8.DecodeRuneInString(s[i:])
+ return s[:i], s[i+size:], true
+ }
+ return s, "", false
+}
+
+// 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")
+}
diff --git a/internal/webtest/webtest_test.go b/internal/webtest/webtest_test.go
new file mode 100644
index 00000000..1705aa1b
--- /dev/null
+++ b/internal/webtest/webtest_test.go
@@ -0,0 +1,85 @@
+// 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.
+
+//go:build go1.16
+// +build go1.16
+
+package webtest
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestWebtestHandler(t *testing.T) {
+ h := http.FileServer(http.Dir("testdata"))
+ testWebtest(t, "testdata/fs*.txt", func(c *case_) error { return c.runHandler(h) })
+}
+
+func echo(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ fmt.Fprintf(w, "%v %v\n", r.Method, r.RequestURI)
+ if err := r.ParseForm(); err != nil {
+ fmt.Fprintf(w, "parsing form: %v\n", err)
+ }
+ for k, v := range r.Form {
+ fmt.Fprintf(w, "%q: %q\n", k, v)
+ }
+ if len(r.Form) == 0 {
+ fmt.Fprintf(w, "no query\n")
+ }
+}
+
+func TestEchoHandler(t *testing.T) {
+ TestHandler(t, "testdata/echo.txt", http.HandlerFunc(echo))
+}
+
+func TestEchoServer(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(echo))
+ defer srv.Close()
+ addr := strings.TrimPrefix(srv.URL, "http://")
+ TestServer(t, "testdata/echo.txt", addr)
+}
+
+func testWebtest(t *testing.T, glob string, do func(*case_) error) {
+ files, err := filepath.Glob(glob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, file := range files {
+ t.Run(filepath.Base(file), func(t *testing.T) {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ script, err := parseScript(file, string(data))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range script.cases {
+ t.Run(c.method+"/"+strings.TrimPrefix(c.url, "/"), func(t *testing.T) {
+ hint := c.hint
+ c.hint = ""
+ if err := do(c); err != nil {
+ if hint == "" {
+ t.Fatal(err)
+ }
+ if !strings.Contains(err.Error(), hint) {
+ t.Fatalf("unexpected error %v (want %q)", err, hint)
+ }
+ return
+ }
+ if hint != "" {
+ t.Fatalf("unexpected success (want %q)", hint)
+ }
+ })
+ }
+ })
+ }
+}