Files
summercms/internal/docsite/theme_test.go
Jakub Zych f5f9387ac9 test(11.2): cover site_url and site_label and gate the framework, app and site stages
- TestCheckSiteURL (21 accepted and rejected values), TestSiteLabel and
  TestSiteURLPrecedence (option over site.yaml, label-without-URL and
  invalid option errors); new TestParseSite rows for blank and two-line labels
- TestSiteLink: escaped label, section page and 404 page, exact unset header
  bytes
- TestDocsSiteFlagsInHelp and the --site-label-without-URL error
- check-phase11.2.sh --framework, --app and --site
2026-10-01 16:35:09 +02:00

369 lines
14 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)
}
}
// TestSiteLink checks the optional link back to the main site on the home
// page, a section page and the 404 page: absent without a site URL (the
// header bytes unchanged), labelled explicitly, by the URL host, or Home,
// and always HTML-escaped.
func TestSiteLink(t *testing.T) {
// unsetHeader is the wordmark's closing tag followed directly by the
// spacer: the header bytes from before site_url existed.
const unsetHeader = `<span class="wordmark-cms">CMS</span></span></a>` + "\n" + `<div class="header-spacer"></div>`
for _, tc := range []struct {
name string
opts Options
want []string
deny []string
}{
{"unset", Options{}, []string{unsetHeader}, []string{"site-link"}},
{"explicit label", Options{SiteURL: "/", SiteLabel: "acme.example"}, []string{`<a class="site-link" href="/" aria-label="acme.example">`, `<span class="site-link-label">acme.example</span>`}, []string{unsetHeader}},
{"host label", Options{SiteURL: "https://acme.example/docs"}, []string{`<a class="site-link" href="https://acme.example/docs"`, `<span class="site-link-label">acme.example</span>`}, nil},
{"host and port label", Options{SiteURL: "http://acme.example:8080/"}, []string{`<a class="site-link" href="http://acme.example:8080/"`, `<span class="site-link-label">acme.example:8080</span>`}, nil},
{"home label", Options{SiteURL: "/"}, []string{`<a class="site-link" href="/"`, `<span class="site-link-label">Home</span>`}, nil},
{"escaped label", Options{SiteURL: "/", SiteLabel: "<b>&"}, []string{`aria-label="&lt;b&gt;&amp;"`, `<span class="site-link-label">&lt;b&gt;&amp;</span>`}, []string{"<b>&"}},
} {
opts := tc.opts
opts.Root, opts.Commands, opts.Out = themeTree(t), fixtureCommands, filepath.Join(t.TempDir(), "site")
if _, problems, err := Build(opts); err != nil || len(problems) > 0 {
t.Fatalf("%s: Build: %v %q", tc.name, err, problemLines(problems))
}
for _, name := range []string{"index.html", "setup/start.html", "404.html"} {
html := readOut(t, opts.Out, name)
for _, w := range tc.want {
if !strings.Contains(html, w) {
t.Errorf("%s: %s lacks %s", tc.name, name, w)
}
}
for _, d := range tc.deny {
if strings.Contains(html, d) {
t.Errorf("%s: %s contains %s", tc.name, name, d)
}
}
}
}
}
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">&#34;x&#34;</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)
}
}
}