diff options
| author | Russ Cox <rsc@golang.org> | 2021-05-18 12:17:17 -0400 |
|---|---|---|
| committer | Russ Cox <rsc@golang.org> | 2021-06-15 02:12:39 +0000 |
| commit | e5882a3721863c3bb2a45547ee0897f28e8c339c (patch) | |
| tree | 6ecffcb657a30685b1f1d6668130f4760b7c8408 | |
| parent | de60b61b0ea7867bb1ded3768939cb8cf43f0e84 (diff) | |
| download | go-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.txt | 14 | ||||
| -rw-r--r-- | internal/webtest/testdata/fs.txt | 31 | ||||
| -rw-r--r-- | internal/webtest/testdata/hello.html | 2 | ||||
| -rw-r--r-- | internal/webtest/webtest.go | 678 | ||||
| -rw-r--r-- | internal/webtest/webtest_test.go | 85 |
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 = ¤t.Case.postbody + case "postquery": + targ = ¤t.Case.postquery + case "posttype": + targ = ¤t.Case.posttype + case "hint": + targ = ¤t.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) + } + }) + } + }) + } +} |
