Files
summercms/internal/docsite/serve_test.go
Jakub Zych 28afd4d197 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)
2026-10-01 00:05:12 +02:00

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")
}
}