- 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)
327 lines
12 KiB
Go
327 lines
12 KiB
Go
package docsite
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// themeTree is a two-section fixture: the index (two H2 headings, a
|
|
// callout), two setup pages and one API page with a Go fence.
|
|
func themeTree(t *testing.T) string {
|
|
t.Helper()
|
|
return writeTree(t, map[string]string{
|
|
"docs/site.yaml": fixtureSite + "edit_url: \"https://forge.example/edit/{path}\"\n",
|
|
"docs/index.md": page("Acme docs", "index", 0,
|
|
"## Alpha\n\nText.\n\n### Detail\n\n> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip.\n\n> [!WARNING]\n> Careful.\n\n"+
|
|
"```sh\n$ summer docs:build\n```\n\n## Beta\n\nMore.\n"),
|
|
"docs/setup/start.md": page("Start", "setup", 10, "## Only heading\n\nText.\n"),
|
|
"docs/setup/second.md": page("Second", "setup", 20, "Text.\n"),
|
|
"modules/alpha/alpha.go": "package alpha\n",
|
|
"modules/alpha/README.md": "# alpha\n\nAlpha does one thing well.\n\n## Usage\n\n" +
|
|
"```go\nfunc main() {\n\treturn \"x\" // done\n}\n```\n\n```yaml\nkey: true\n```\n",
|
|
})
|
|
}
|
|
|
|
func buildTheme(t *testing.T) string {
|
|
t.Helper()
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
if _, problems, err := Build(Options{Root: themeTree(t), Commands: fixtureCommands, Out: out}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func readOut(t *testing.T, out, name string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(out, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
var (
|
|
scriptTag = regexp.MustCompile(`<script[^>]*>`)
|
|
anyTag = regexp.MustCompile(`<[a-zA-Z][^>]*>`)
|
|
styleAttr = regexp.MustCompile(`\sstyle=`)
|
|
handlerAttr = regexp.MustCompile(`\son[a-z]+=`)
|
|
)
|
|
|
|
// assertCSPSafe fails on inline script bodies, inline styles and inline
|
|
// event handlers (script-src 'self'; style-src 'self').
|
|
func assertCSPSafe(t *testing.T, name, html string) {
|
|
t.Helper()
|
|
for _, loc := range scriptTag.FindAllStringIndex(html, -1) {
|
|
tag := html[loc[0]:loc[1]]
|
|
if !strings.Contains(tag, ` src="`) || !strings.HasPrefix(html[loc[1]:], "</script>") {
|
|
t.Errorf("%s has an inline script: %s", name, tag)
|
|
}
|
|
}
|
|
for _, tag := range anyTag.FindAllString(html, -1) {
|
|
if styleAttr.MatchString(tag) || handlerAttr.MatchString(tag) {
|
|
t.Errorf("%s has an inline style or handler: %s", name, tag)
|
|
}
|
|
}
|
|
if strings.Contains(html, "<style") {
|
|
t.Errorf("%s has a <style> element", name)
|
|
}
|
|
}
|
|
|
|
func TestBuildSiteMarkers(t *testing.T) {
|
|
out := buildTheme(t)
|
|
index := readOut(t, out, "index.html")
|
|
for _, marker := range []string{
|
|
`<body class="docs">`,
|
|
`<header class="site-header">`,
|
|
`<nav class="sidebar" aria-label="Documentation">`,
|
|
`<aside class="toc" aria-label="On this page">`,
|
|
`<details class="toc-inline">`,
|
|
`<div class="page-actions">`,
|
|
`<nav class="pager" aria-label="Previous and next page">`,
|
|
`<footer class="site-footer">`,
|
|
`<dialog class="search" id="search">`,
|
|
`<button class="theme-toggle"`,
|
|
`<figure class="code">`,
|
|
`<aside class="callout callout-note" role="note">`,
|
|
`<aside class="callout callout-tip" role="note">`,
|
|
`<aside class="callout callout-warning" role="note">`,
|
|
`<a class="heading-anchor" href="#alpha" aria-label="Link to section: Alpha">#</a>`,
|
|
`<svg class="icon icon-`,
|
|
`<link rel="stylesheet" href="/assets/site.css">`,
|
|
`<script src="/assets/theme-init.js"></script>`,
|
|
`<meta name="color-scheme" content="light dark">`,
|
|
`<meta property="og:title" content="Acme docs">`,
|
|
`<link rel="alternate" type="text/markdown" href="/index.md">`,
|
|
`href="https://forge.example/edit/docs/index.md"`,
|
|
`View as Markdown`,
|
|
`aria-label="SummerCMS documentation home"`,
|
|
`aria-controls="sidebar"`,
|
|
`<span class="tok-prompt">$ </span><span class="tok-kw">summer</span>`,
|
|
`href="/llms.txt"`,
|
|
`href="/llms-full.txt"`,
|
|
`class="toc-h3"`,
|
|
} {
|
|
if !strings.Contains(index, marker) {
|
|
t.Errorf("index.html missing %s", marker)
|
|
}
|
|
}
|
|
if strings.Contains(index, "[!NOTE]") {
|
|
t.Error("callout marker line rendered")
|
|
}
|
|
if i, j := strings.Index(index, "theme-init.js"), strings.Index(index, "site.css"); i < 0 || j < 0 || i > j {
|
|
t.Error("theme-init.js must load before the stylesheet")
|
|
}
|
|
|
|
// First page: Next only (the empty Previous cell keeps Next on the right).
|
|
if !strings.Contains(index, `<span class="pager-empty"></span>`) || strings.Contains(index, "pager-prev") || !strings.Contains(index, "pager-next") {
|
|
t.Error("index.html pager should hold Next only")
|
|
}
|
|
// One H2 and no H3: no TOC; the pager crosses into the API section.
|
|
start := readOut(t, out, "setup/start.html")
|
|
if strings.Contains(start, `class="toc"`) || strings.Contains(start, "toc-inline") {
|
|
t.Error("setup/start.html has a TOC with one heading")
|
|
}
|
|
if !strings.Contains(start, `<p class="eyebrow">Setup</p>`) {
|
|
t.Error("setup/start.html has no section eyebrow")
|
|
}
|
|
second := readOut(t, out, "setup/second.html")
|
|
if !strings.Contains(second, `<span class="pager-section">API reference</span>`) {
|
|
t.Error("cross-section Next has no section line")
|
|
}
|
|
if strings.Count(second, `class="pager-section"`) != 1 {
|
|
t.Error("same-section Previous must not carry a section line")
|
|
}
|
|
// Last page: Previous only.
|
|
api := readOut(t, out, "api/alpha.html")
|
|
if !strings.Contains(api, "pager-prev") || strings.Contains(api, "pager-next") {
|
|
t.Error("api/alpha.html pager should hold Previous only")
|
|
}
|
|
for _, want := range []string{
|
|
`<span class="tok-kw">func</span>`,
|
|
`<span class="tok-str">"x"</span>`,
|
|
`<span class="tok-com">// done</span>`,
|
|
`<span class="tok-key">key</span>`,
|
|
`<span class="tok-num">true</span>`,
|
|
`<code class="language-go">`,
|
|
`<button type="button" class="copy-button" aria-label="Copy code" hidden>`,
|
|
`<div class="sidebar-section sidebar-api">`,
|
|
`aria-current="page"`,
|
|
} {
|
|
if !strings.Contains(api, want) {
|
|
t.Errorf("api/alpha.html missing %s", want)
|
|
}
|
|
}
|
|
|
|
notFound := readOut(t, out, "404.html")
|
|
for _, want := range []string{`<body class="docs docs-404">`, "<h1>Page not found</h1>", "This page does not exist or has moved.", `>documentation home</a>`} {
|
|
if !strings.Contains(notFound, want) {
|
|
t.Errorf("404.html missing %s", want)
|
|
}
|
|
}
|
|
for _, not := range []string{`aria-current="page"`, `class="toc"`, `class="pager"`, "page-actions"} {
|
|
if strings.Contains(notFound, not) {
|
|
t.Errorf("404.html must not contain %s", not)
|
|
}
|
|
}
|
|
|
|
for _, name := range []string{"index.html", "setup/start.html", "setup/second.html", "api/alpha.html", "404.html"} {
|
|
html := readOut(t, out, name)
|
|
assertCSPSafe(t, name, html)
|
|
if strings.Contains(html, "//fonts.") || strings.Contains(html, "cdn") {
|
|
t.Errorf("%s references a third-party asset origin", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"assets/site.js", "assets/search.js", "assets/theme-init.js", "assets/fonts/dm-mono-latin-400-normal.woff2"} {
|
|
if _, err := os.Stat(filepath.Join(out, name)); err != nil {
|
|
t.Errorf("missing %s", name)
|
|
}
|
|
}
|
|
|
|
// A one-page site renders no pager.
|
|
var buf bytes.Buffer
|
|
if err := pageTmpl.ExecuteTemplate(&buf, "pager", pageView{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.TrimSpace(buf.String()) != "" {
|
|
t.Errorf("pager without prev/next = %q, want nothing", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestServeHandler(t *testing.T) {
|
|
srv := httptest.NewServer(Handler(buildTheme(t)))
|
|
defer srv.Close()
|
|
get := func(p string) (int, string) {
|
|
t.Helper()
|
|
res, err := http.Get(srv.URL + p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
body, _ := io.ReadAll(res.Body)
|
|
return res.StatusCode, string(body)
|
|
}
|
|
for _, p := range []string{"/index.html", "/", "/setup/start.html", "/assets/site.css", "/search-index.json"} {
|
|
if code, _ := get(p); code != http.StatusOK {
|
|
t.Errorf("GET %s = %d, want 200", p, code)
|
|
}
|
|
}
|
|
for _, p := range []string{"/missing.html", "/setup/", "/.summer-docs", "/assets/../.summer-docs", "/%2e%2e/etc/passwd"} {
|
|
code, body := get(p)
|
|
if code != http.StatusNotFound || !strings.Contains(body, "Page not found") {
|
|
t.Errorf("GET %s = %d, want 404 with the Page not found body", p, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// syncBuffer is a goroutine-safe bytes.Buffer.
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *syncBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
var servingLine = regexp.MustCompile(`Serving docs at (http://\S+) \(press Ctrl\+C to stop\)`)
|
|
|
|
func TestServeRefusesNonLoopback(t *testing.T) {
|
|
opts := Options{Root: themeTree(t), Commands: fixtureCommands}
|
|
for _, addr := range []string{"0.0.0.0:8088", "192.0.2.1:8088", "example.com:8088", ":8088"} {
|
|
var out bytes.Buffer
|
|
err := Serve(context.Background(), opts, addr, false, &out)
|
|
want := "docs:serve: refusing to listen on " + addr + ": not a loopback address. Pass --allow-remote to serve on the network."
|
|
if err == nil || err.Error() != want {
|
|
t.Errorf("Serve(%s) = %v, want %q", addr, err, want)
|
|
}
|
|
}
|
|
if err := checkServeAddr("0.0.0.0:0", true); err != nil {
|
|
t.Errorf("--allow-remote still refused: %v", err)
|
|
}
|
|
|
|
for _, addr := range []string{"127.0.0.1:0", "localhost:0"} {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
out := &syncBuffer{}
|
|
done := make(chan error, 1)
|
|
go func() { done <- Serve(ctx, opts, addr, false, out) }()
|
|
var base string
|
|
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
|
|
if m := servingLine.FindStringSubmatch(out.String()); m != nil {
|
|
base = m[1]
|
|
break
|
|
}
|
|
}
|
|
if base == "" {
|
|
cancel()
|
|
t.Fatalf("Serve(%s) printed no serving line: %s", addr, out.String())
|
|
}
|
|
res, err := http.Get(base + "/index.html")
|
|
if err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Errorf("Serve(%s) GET /index.html = %d", addr, res.StatusCode)
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Errorf("Serve(%s) = %v after cancel", addr, err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("Serve(%s) did not return after cancel", addr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestThemeAssetsAndPager checks the full asset set every build ships and
|
|
// the pager edge that points back at the index page.
|
|
func TestThemeAssetsAndPager(t *testing.T) {
|
|
out := buildTheme(t)
|
|
for _, name := range []string{
|
|
"assets/site.css", "assets/site.js", "assets/search.js", "assets/theme-init.js",
|
|
"assets/LICENSE-lucide.txt", "assets/fonts/LICENSE-dm-sans.txt", "assets/fonts/LICENSE-dm-mono.txt",
|
|
"assets/fonts/dm-sans-latin-400-normal.woff2", "assets/fonts/dm-sans-latin-600-normal.woff2",
|
|
"assets/fonts/dm-sans-latin-ext-400-normal.woff2", "assets/fonts/dm-sans-latin-ext-600-normal.woff2",
|
|
"assets/fonts/dm-sans-latin-400-italic.woff2", "assets/fonts/dm-sans-latin-ext-400-italic.woff2",
|
|
"assets/fonts/dm-mono-latin-400-normal.woff2", "assets/fonts/dm-mono-latin-ext-400-normal.woff2",
|
|
MarkerFile,
|
|
} {
|
|
if st, err := os.Stat(filepath.Join(out, name)); err != nil || st.Size() == 0 {
|
|
t.Errorf("missing or empty %s: %v", name, err)
|
|
}
|
|
}
|
|
// setup/start's Previous is the index page: no section line.
|
|
start := readOut(t, out, "setup/start.html")
|
|
prev := start[strings.Index(start, "pager-prev"):]
|
|
prev = prev[:strings.Index(prev, "</a>")]
|
|
if strings.Contains(prev, "pager-section") || !strings.Contains(prev, "Acme docs") {
|
|
t.Errorf("pager back to the index = %q", prev)
|
|
}
|
|
for _, name := range []string{"index.html", "setup/start.html"} {
|
|
if html := readOut(t, out, name); !strings.Contains(html, `<title>`) || strings.Count(html, "<h1") != 1 {
|
|
t.Errorf("%s must have a title and exactly one H1", name)
|
|
}
|
|
}
|
|
}
|