- 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)
266 lines
8.7 KiB
Go
266 lines
8.7 KiB
Go
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")
|
|
}
|
|
}
|