- 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
369 lines
14 KiB
Go
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="<b>&"`, `<span class="site-link-label"><b>&</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">"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)
|
|
}
|
|
}
|
|
}
|