test(11.1-06): branch-level tests for every docsite stage (94.8% coverage)
- load, render, emit, snippet, highlight, serve and checker branch tests; docs:build --check, docs:sync and docs:serve output tests in cmd/summer - fix: a CRLF or BOM page is reported as such, not as a page without a frontmatter block (TestFrontmatterEncodingProblems) - fix: src=#Type.Method finds a method with a parenthesised receiver (TestSnippetGenericsAndGroups, Box.Paren) - fix: docs:serve adds its watches before announcing the server, so an edit made right after the serving line rebuilds (TestServeWatchRebuilds)
This commit is contained in:
265
internal/docsite/serve_test.go
Normal file
265
internal/docsite/serve_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestServeAddrPolicy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
addr string
|
||||
allowRemote bool
|
||||
want string // "" accepts; otherwise an error substring
|
||||
}{
|
||||
{"127.0.0.1:8088", false, ""},
|
||||
{"127.0.0.2:0", false, ""},
|
||||
{"[::1]:8088", false, ""},
|
||||
{"localhost:8088", false, ""},
|
||||
{"0.0.0.0:8088", false, "not a loopback address"},
|
||||
{"[::]:8088", false, "not a loopback address"},
|
||||
{"192.168.1.10:8088", false, "not a loopback address"},
|
||||
{"docs.example.com:8088", false, "not a loopback address"},
|
||||
{":8088", false, "not a loopback address"},
|
||||
{"0.0.0.0:8088", true, ""},
|
||||
{"[::]:8088", true, ""},
|
||||
{"docs.example.com:8088", true, ""},
|
||||
{"127.0.0.1", false, "invalid --addr"},
|
||||
{"127.0.0.1", true, "invalid --addr"},
|
||||
} {
|
||||
err := checkServeAddr(tc.addr, tc.allowRemote)
|
||||
if (tc.want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), tc.want)) {
|
||||
t.Errorf("checkServeAddr(%q, %v) = %v, want %q", tc.addr, tc.allowRemote, err, tc.want)
|
||||
}
|
||||
}
|
||||
if DefaultServeAddr != "127.0.0.1:8088" || checkServeAddr(DefaultServeAddr, false) != nil {
|
||||
t.Errorf("DefaultServeAddr %s must be loopback", DefaultServeAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHandlerBranches(t *testing.T) {
|
||||
out := buildTheme(t)
|
||||
if err := os.MkdirAll(filepath.Join(out, "nested", "dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(out, "nested", "index.html"), []byte("nested index"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(Handler(out))
|
||||
defer srv.Close()
|
||||
do := func(method, p string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, srv.URL+p, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return res, string(body)
|
||||
}
|
||||
if res, body := do(http.MethodGet, "/nested/"); res.StatusCode != http.StatusOK || body != "nested index" {
|
||||
t.Errorf("GET /nested/ = %d %q", res.StatusCode, body)
|
||||
}
|
||||
if res, body := do(http.MethodHead, "/index.html"); res.StatusCode != http.StatusOK || body != "" {
|
||||
t.Errorf("HEAD /index.html = %d %q", res.StatusCode, body)
|
||||
}
|
||||
for _, p := range []string{"/nested/dir/", "/.hidden/x", "/assets/.x.css", "/nope"} {
|
||||
res, body := do(http.MethodGet, p)
|
||||
if res.StatusCode != http.StatusNotFound || !strings.Contains(body, "Page not found") ||
|
||||
!strings.HasPrefix(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("GET %s = %d %q", p, res.StatusCode, res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
res, _ := do(m, "/index.html")
|
||||
if res.StatusCode != http.StatusMethodNotAllowed || res.Header.Get("Allow") != "GET, HEAD" {
|
||||
t.Errorf("%s = %d Allow=%q", m, res.StatusCode, res.Header.Get("Allow"))
|
||||
}
|
||||
}
|
||||
|
||||
// Without a 404.html the handler still answers 404.
|
||||
bare := httptest.NewServer(Handler(t.TempDir()))
|
||||
defer bare.Close()
|
||||
res, err := http.Get(bare.URL + "/missing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("bare 404 = %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRebuildKeepsLastGoodBuild(t *testing.T) {
|
||||
root := themeTree(t)
|
||||
out := &syncBuffer{}
|
||||
s := &server{opts: Options{Root: root, Commands: fixtureCommands}, tmp: t.TempDir(), out: out}
|
||||
s.opts, _ = s.opts.normalize()
|
||||
if !s.rebuild() {
|
||||
t.Fatalf("first build failed: %s", out.String())
|
||||
}
|
||||
first := s.dir()
|
||||
if filepath.Base(first) != "build-1" {
|
||||
t.Fatalf("first build dir = %s", first)
|
||||
}
|
||||
|
||||
// A problem keeps the previous build and says so.
|
||||
index := filepath.Join(root, "docs", "index.md")
|
||||
good, err := os.ReadFile(index)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(index, append(append([]byte{}, good...), "\nSee `summer no:such`.\n"...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.rebuild() {
|
||||
t.Fatal("rebuild accepted a planted problem")
|
||||
}
|
||||
if s.dir() != first {
|
||||
t.Fatalf("serving %s after a failed build, want %s", s.dir(), first)
|
||||
}
|
||||
for _, want := range []string{`command: "no:such" is not a summer or application command`, "docs:serve: build failed, still serving the previous version"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.tmp, "build-2")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("the failed build directory was kept")
|
||||
}
|
||||
|
||||
// A load error is printed too.
|
||||
site := filepath.Join(root, "docs", "site.yaml")
|
||||
cfg, err := os.ReadFile(site)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(site); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.rebuild() || !strings.Contains(out.String(), "read site config") {
|
||||
t.Fatalf("rebuild without site.yaml: %s", out.String())
|
||||
}
|
||||
if err := os.WriteFile(site, cfg, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two good builds later the first directory is gone; the one before
|
||||
// the current build is kept for in-flight requests.
|
||||
if err := os.WriteFile(index, good, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.rebuild() || !s.rebuild() {
|
||||
t.Fatalf("rebuilds failed: %s", out.String())
|
||||
}
|
||||
if _, err := os.Stat(first); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("%s survived two newer builds", first)
|
||||
}
|
||||
if _, err := os.Stat(s.stale); err != nil {
|
||||
t.Errorf("the previous build %s was removed early", s.stale)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.dir(), "index.html")); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStartupFailures(t *testing.T) {
|
||||
// A tree with a problem never starts serving.
|
||||
root := themeTree(t)
|
||||
appendFile(t, root, "docs/index.md", "\n`summer no:such`\n")
|
||||
var out syncBuffer
|
||||
err := Serve(context.Background(), Options{Root: root, Commands: fixtureCommands}, "127.0.0.1:0", false, &out)
|
||||
if err == nil || err.Error() != "docs:serve: build failed" {
|
||||
t.Fatalf("Serve with a problem = %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "still serving") || !strings.Contains(out.String(), "no:such") {
|
||||
t.Errorf("output = %s", out.String())
|
||||
}
|
||||
|
||||
// A taken address is reported.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
err = Serve(context.Background(), Options{Root: themeTree(t), Commands: fixtureCommands}, ln.Addr().String(), false, &out)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "docs:serve: ") {
|
||||
t.Fatalf("Serve on a taken address = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeWatchRebuilds edits a page while Serve runs and waits for the
|
||||
// new build, then plants a problem and checks the old build stays up.
|
||||
func TestServeWatchRebuilds(t *testing.T) {
|
||||
root := themeTree(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
out := &syncBuffer{}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Serve(ctx, Options{Root: root, Commands: fixtureCommands}, "127.0.0.1:0", false, out) }()
|
||||
waitFor := func(what string, cond func() bool) {
|
||||
t.Helper()
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s; output:\n%s", what, out.String())
|
||||
}
|
||||
var base string
|
||||
waitFor("the serving line", func() bool {
|
||||
if m := servingLine.FindStringSubmatch(out.String()); m != nil {
|
||||
base = m[1]
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
get := func(p string) string {
|
||||
t.Helper()
|
||||
res, err := http.Get(base + p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// A dot-file change is ignored; a page edit rebuilds.
|
||||
writeFile(t, root, "docs/.scratch", "x")
|
||||
appendFile(t, root, "docs/setup/second.md", "\nFreshly edited.\n")
|
||||
waitFor("a rebuild", func() bool { return strings.Contains(out.String(), "docs:serve: rebuilt") })
|
||||
waitFor("the edited page", func() bool { return strings.Contains(get("/setup/second.html"), "Freshly edited.") })
|
||||
|
||||
// A new page rebuilds and is served.
|
||||
writeFile(t, root, "docs/setup/third.md", page("Third", "setup", 90, "Brand new.\n"))
|
||||
waitFor("the new page", func() bool { return strings.Contains(get("/setup/third.html"), "Brand new.") })
|
||||
|
||||
// A planted problem keeps the last good build online.
|
||||
appendFile(t, root, "docs/setup/second.md", "\n`summer no:such`\n")
|
||||
waitFor("the failed rebuild", func() bool { return strings.Contains(out.String(), "still serving the previous version") })
|
||||
if !strings.Contains(get("/setup/second.html"), "Freshly edited.") {
|
||||
t.Error("the last good build is no longer served")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve = %v after cancel", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Serve did not return after cancel")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user