Files
summercms/internal/docsite/load_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

434 lines
19 KiB
Go

package docsite
import (
"errors"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestParseSite(t *testing.T) {
const valid = "title: Acme\ndescription: Acme docs.\nsections:\n - name: setup\n title: Setup\n"
s, err := ParseSite([]byte(valid))
if err != nil {
t.Fatal(err)
}
if s.Title != "Acme" || len(s.Sections) != 1 || s.sectionTitle("setup") != "Setup" || s.sectionTitle("other") != "other" {
t.Fatalf("ParseSite = %+v", s)
}
for _, tc := range []struct{ name, raw, want string }{
{"unknown key", valid + "colour: red\n", "docsite: parse site config: "},
{"not a mapping", "- a\n- b\n", "docsite: parse site config: "},
{"no title", "description: d\nsections:\n - name: a\n title: A\n", "title is required"},
{"no description", "title: t\nsections:\n - name: a\n title: A\n", "description is required"},
{"no sections", "title: t\ndescription: d\n", "sections is required"},
{"section without title", "title: t\ndescription: d\nsections:\n - name: a\n", "every section needs a name and a title"},
{"reserved index", "title: t\ndescription: d\nsections:\n - name: index\n title: I\n", `section name "index" is reserved`},
{"bad slug", "title: t\ndescription: d\nsections:\n - name: Setup\n title: S\n", `section name "Setup" must be lowercase`},
{"duplicate", "title: t\ndescription: d\nsections:\n - name: a\n title: A\n - name: a\n title: B\n", `section "a" is listed twice`},
{"site_url javascript", valid + "site_url: javascript:alert(1)\n", "site_url must be an http(s) URL with a host or a path starting with a single /"},
{"site_url protocol-relative", valid + "site_url: //acme.example\n", "site_url must be an http(s) URL with a host or a path starting with a single /"},
{"site_label without site_url", valid + "site_label: Acme\n", "site_label needs site_url"},
{"blank site_label", valid + "site_url: /\nsite_label: \" \"\n", "site_label must be one non-empty line"},
{"two-line site_label", valid + "site_url: /\nsite_label: \"a\\nb\"\n", "site_label must be one non-empty line"},
{"site_url with a newline", valid + "site_url: \"/a\\nb\"\n", "site_url must be an http(s) URL"},
} {
if _, err := ParseSite([]byte(tc.raw)); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: err = %v, want %q", tc.name, err, tc.want)
}
}
for _, u := range []string{"https://acme.example/", "/"} {
got, err := ParseSite([]byte(valid + "site_url: " + u + "\n"))
if err != nil || got.SiteURL != u {
t.Errorf("site_url %q: ParseSite = %+v, %v", u, got, err)
}
}
if got, err := ParseSite([]byte(valid + "site_url: /\nsite_label: Acme\n")); err != nil || got.SiteURL != "/" || got.SiteLabel != "Acme" {
t.Errorf("site_url with site_label: ParseSite = %+v, %v", got, err)
}
if strings.Contains(func() string { _, err := ParseSite([]byte(valid + "colour: red\n")); return err.Error() }(), "\n") {
t.Error("a decode error must be reported on one line")
}
dir := t.TempDir()
path := filepath.Join(dir, "site.yaml")
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
t.Fatal(err)
}
if got, err := LoadSite(path); err != nil || got.Title != "Acme" {
t.Fatalf("LoadSite = %+v, %v", got, err)
}
if _, err := LoadSite(filepath.Join(dir, "missing.yaml")); err == nil || !strings.Contains(err.Error(), "read site config") {
t.Fatalf("LoadSite(missing) = %v", err)
}
}
func TestSplitFrontmatter(t *testing.T) {
for _, tc := range []struct {
name string
raw string
ok bool
fm, body string
bodyLine int
}{
{"plain", "---\ntitle: A\n---\n# A\n", true, "title: A\n", "# A\n", 4},
{"trailing spaces on the closing line", "---\ntitle: A\n--- \n# A\n", true, "title: A\n", "# A\n", 4},
{"no body", "---\ntitle: A\n---", true, "title: A\n", "", 4},
{"no body with newline", "---\ntitle: A\n---\n", true, "title: A\n", "", 4},
{"empty block", "---\n---\n# A\n", true, "", "# A\n", 3},
{"no closing delimiter", "---\ntitle: A\n# A\n", false, "", "", 0},
{"no opening delimiter", "title: A\n---\n# A\n", false, "", "", 0},
{"CRLF line endings", "---\r\ntitle: A\r\n---\r\n# A\r\n", false, "", "", 0},
{"byte order mark", "\xef\xbb\xbf---\ntitle: A\n---\n# A\n", false, "", "", 0},
} {
fm, body, line, ok := splitFrontmatter([]byte(tc.raw))
if ok != tc.ok || string(fm) != tc.fm || string(body) != tc.body || line != tc.bodyLine {
t.Errorf("%s: splitFrontmatter = %q, %q, %d, %v; want %q, %q, %d, %v",
tc.name, fm, body, line, ok, tc.fm, tc.body, tc.bodyLine, tc.ok)
}
}
}
// TestFrontmatterEncodingProblems pins the problem a CRLF or BOM page gets:
// it names the encoding, not a missing frontmatter block the file has.
func TestFrontmatterEncodingProblems(t *testing.T) {
crlf := strings.ReplaceAll(page("Win", "setup", 30, "Text.\n"), "\n", "\r\n")
bom := "\xef\xbb\xbf" + page("Bom", "setup", 40, "Text.\n")
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
"docs/setup/win.md": crlf,
"docs/setup/bom.md": bom,
}), []string{
"docs/setup/bom.md:1: frontmatter: the file starts with a UTF-8 byte order mark; save it without one",
"docs/setup/win.md:1: frontmatter: the file uses CRLF line endings; save it with LF line endings",
})
}
func TestParseFrontmatter(t *testing.T) {
fm, details := parseFrontmatter([]byte("title: A\ndescription: B\nsection: setup\norder: 3\n"))
if len(details) > 0 || fm != (Frontmatter{Title: "A", Description: "B", Section: "setup", Order: 3}) {
t.Fatalf("parseFrontmatter = %+v %q", fm, details)
}
for _, tc := range []struct {
name, raw string
want []string
}{
{"unknown fields sorted, then missing", "title: A\nzeta: 1\nalpha: 2\nsection: s\norder: 1\n",
[]string{`unknown field "alpha"`, `unknown field "zeta"`, `missing field "description"`}},
{"empty and null values are missing", "title: \"\"\ndescription: ~\nsection: s\norder: 1\n",
[]string{`missing field "title"`, `missing field "description"`}},
{"everything missing", "{}\n",
[]string{`missing field "title"`, `missing field "description"`, `missing field "section"`, `missing field "order"`}},
} {
if _, got := parseFrontmatter([]byte(tc.raw)); !slices.Equal(got, tc.want) {
t.Errorf("%s: details = %q, want %q", tc.name, got, tc.want)
}
}
for _, raw := range []string{"- a\n- b\n", "title: A\ndescription: B\nsection: s\norder: x\n"} {
if _, got := parseFrontmatter([]byte(raw)); len(got) != 1 || strings.Contains(got[0], "\n") {
t.Errorf("parseFrontmatter(%q) = %q, want one single-line decode error", raw, got)
}
}
}
func TestWalkPagesExclusions(t *testing.T) {
root := writeTree(t, map[string]string{
"docs/index.md": "x",
"docs/setup/a.md": "x",
"docs/setup/examples/b.md": "x",
"docs/setup/notes.txt": "x",
"docs/setup/_draft.md": "x",
"docs/setup/.hidden.md": "x",
"docs/_partials/c.md": "x",
"docs/.cache/d.md": "x",
"docs/examples/blog/README.md": "x",
"docs/examples/blog/plugin.go": "x",
"docs/archive/examples/keep.md": "x",
})
files, err := walkPages(filepath.Join(root, "docs"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, f := range files {
rel, _ := filepath.Rel(root, f)
got = append(got, filepath.ToSlash(rel))
}
want := []string{"docs/archive/examples/keep.md", "docs/index.md", "docs/setup/a.md", "docs/setup/examples/b.md"}
if !slices.Equal(got, want) {
t.Fatalf("walkPages = %v, want %v", got, want)
}
if _, err := walkPages(filepath.Join(root, "nope")); err == nil {
t.Fatal("walkPages on a missing directory returned no error")
}
}
func TestSplitReadme(t *testing.T) {
for _, tc := range []struct {
name, raw string
ok bool
title, desc string
body string
}{
{"summary after blank lines", "# alpha\n\n\n Alpha does it. \n\n## Usage\n", true, "alpha", "Alpha does it.", "# alpha\n\n\n\n\n## Usage\n"},
{"summary on line two", "# alpha\nAlpha does it.\n", true, "alpha", "Alpha does it.", "# alpha\n\n"},
{"missing H1", "## alpha\n\nAlpha.\n", false, "", "", ""},
{"no heading at all", "alpha\n\nAlpha.\n", false, "", "", ""},
{"empty title", "# \n\nAlpha.\n", false, "", "", ""},
{"heading instead of summary", "# alpha\n\n## Usage\n", false, "", "", ""},
{"title only", "# alpha\n\n", false, "", "", ""},
{"empty file", "", false, "", "", ""},
} {
title, desc, body, ok := splitReadme([]byte(tc.raw))
if ok != tc.ok || title != tc.title || desc != tc.desc || string(body) != tc.body {
t.Errorf("%s: splitReadme = %q, %q, %q, %v", tc.name, title, desc, body, ok)
}
}
}
func TestModuleNames(t *testing.T) {
root := writeTree(t, map[string]string{
"modules/zeta/zeta.go": "package zeta\n",
"modules/alpha/alpha.go": "package alpha\n",
"modules/tests/only_test.go": "package tests\n",
"modules/nested/sub/sub.go": "package sub\n",
"modules/_skip/skip.go": "package skip\n",
"modules/.hidden/hidden.go": "package hidden\n",
"modules/file.go": "package modules\n",
"modules/docs/README.md": "# docs\n\nNo Go.\n",
"modules/gofolder.go/keep.md": "not a Go file\n",
})
names, err := moduleNames(root)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(names, []string{"alpha", "zeta"}) {
t.Fatalf("moduleNames = %v", names)
}
if names, err := moduleNames(t.TempDir()); err != nil || names != nil {
t.Fatalf("moduleNames without modules/ = %v, %v", names, err)
}
}
func TestLoadSectionRules(t *testing.T) {
// A module without an api section in site.yaml.
noAPI := "title: Acme\ndescription: Acme docs.\nsections:\n - name: setup\n title: Setup\n - name: \"empty\"\n title: Empty\n"
root := writeTree(t, map[string]string{
"docs/site.yaml": noAPI,
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
"modules/fixture/fixture.go": "package fixture\n",
"modules/fixture/README.md": "# fixture\n\nFixture.\n",
})
_, problems, err := Pages(Options{Root: root})
if err != nil {
t.Fatal(err)
}
assertProblems(t, problemLines(problems), []string{
`docs/site.yaml:1: section: "api" is not listed (every module README is published there)`,
`docs/site.yaml:6: section: "empty" has no pages (add the section in the same change as its first page)`,
})
// An invalid site.yaml stops the load with one site problem.
root = writeTree(t, map[string]string{"docs/site.yaml": "title: Acme\n"})
pages, problems, err := Pages(Options{Root: root})
if err != nil || pages != nil {
t.Fatalf("Pages = %v, %v", pages, err)
}
assertProblems(t, problemLines(problems), []string{"docs/site.yaml:1: site: site config: description is required"})
// A missing site.yaml is an error, not a problem.
if _, _, err := Pages(Options{Root: t.TempDir()}); err == nil || !strings.Contains(err.Error(), "read site config") {
t.Fatalf("Pages without site.yaml = %v", err)
}
if _, err := Check(Options{Root: t.TempDir(), Commands: fixtureCommands}); err == nil {
t.Fatal("Check without site.yaml returned no error")
}
if _, _, err := Build(Options{Root: t.TempDir(), Out: filepath.Join(t.TempDir(), "site"), Commands: fixtureCommands}); err == nil {
t.Fatal("Build without site.yaml returned no error")
}
}
func TestSectionLine(t *testing.T) {
raw := []byte("title: t\nsections:\n - name: setup\n title: S\n - name: 'quoted'\n title: Q\n - name: \"api\"\n")
for name, want := range map[string]int{"setup": 3, "quoted": 5, "api": 7, "missing": 1} {
if got := sectionLine(raw, name); got != want {
t.Errorf("sectionLine(%s) = %d, want %d", name, got, want)
}
}
}
func TestLoadOrderAndSources(t *testing.T) {
root := writeTree(t, map[string]string{
"docs/site.yaml": fixtureSite,
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
"docs/setup/b.md": page("Bee", "setup", 10, "Text.\n"),
"docs/setup/a.md": page("Ay", "setup", 10+10, "Text.\n"),
"docs/setup/c.md": page("Cee", "setup", 5, "Text.\n"),
"modules/zeta/zeta.go": "package zeta\n",
"modules/zeta/README.md": "# zeta\n\nZeta.\n",
"modules/beta/beta.go": "package beta\n",
"modules/beta/README.md": "# beta\n\nBeta.\n",
})
pages, problems, err := Pages(Options{Root: root})
if err != nil || len(problems) > 0 {
t.Fatalf("Pages: %v %q", err, problemLines(problems))
}
var urls []string
for _, p := range pages {
urls = append(urls, p.URL)
}
if want := []string{"index", "setup/c", "setup/b", "setup/a", "api/beta", "api/zeta"}; !slices.Equal(urls, want) {
t.Fatalf("reading order = %v, want %v", urls, want)
}
if pages[1].Source != "docs/setup/c.md" || pages[1].BodyLine != 7 || !strings.HasPrefix(string(pages[1].Body), "# Cee\n") {
t.Fatalf("setup/c = %+v", pages[1])
}
// A Src outside the root is shown as an absolute path.
src := filepath.Join(t.TempDir(), "docs")
if err := os.Rename(filepath.Join(root, "docs"), src); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(src, "setup/c.md")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "setup/c.md"), []byte("no frontmatter\n"), 0o644); err != nil {
t.Fatal(err)
}
_, problems, err = Pages(Options{Root: root, Src: src})
if err != nil {
t.Fatal(err)
}
want := filepath.ToSlash(filepath.Join(src, "setup/c.md")) + `:1: frontmatter: the file must start with a "---" frontmatter block closed by a "---" line`
assertProblems(t, problemLines(problems), []string{want})
}
func TestNormalizeDefaults(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
o, err := Options{}.normalize()
if err != nil {
t.Fatal(err)
}
if o.Root != wd || o.Src != filepath.Join(wd, "docs") || o.Out != filepath.Join(wd, "site") {
t.Fatalf("normalize() = %+v", o)
}
o, err = Options{Root: "a/../b", Src: "x", Out: "y/"}.normalize()
if err != nil {
t.Fatal(err)
}
if o.Root != filepath.Join(wd, "b") || o.Src != filepath.Join(wd, "x") || o.Out != filepath.Join(wd, "y") {
t.Fatalf("normalize(relative) = %+v", o)
}
}
// TestCheckSiteURL pins the site_url policy (T-11.2-09): an http(s) URL
// with a host and no user info, or a path starting with exactly one /.
func TestCheckSiteURL(t *testing.T) {
for _, tc := range []struct {
name, raw string
ok bool
}{
{"https host", "https://acme.example", true},
{"https host slash", "https://acme.example/", true},
{"http port path", "http://acme.example:8080/x", true},
{"root path", "/", true},
{"path", "/home", true},
{"empty", "", false},
{"javascript", "javascript:alert(1)", false},
{"javascript mixed case", "JavaScript:alert(1)", false},
{"data", "data:text/html,x", false},
{"protocol-relative", "//acme.example", false},
{"bare host", "acme.example", false},
{"relative path", "docs/x", false},
{"scheme without host", "https://", false},
{"user info", "https://user:pw@acme.example", false},
{"ftp", "ftp://acme.example", false},
{"space in path", "/ x", false},
{"newline", "https://acme.example/\nx", false},
{"leading tab", "\t/home", false},
{"backslash host", "/\\acme.example", false},
{"mailto opaque", "mailto:docs@acme.example", false},
{"control character", "/home\x00", false},
} {
t.Run(tc.name, func(t *testing.T) {
err := checkSiteURL(tc.raw)
if tc.ok && err != nil {
t.Fatalf("checkSiteURL(%q) = %v, want accepted", tc.raw, err)
}
if !tc.ok && !errors.Is(err, errSiteURL) {
t.Fatalf("checkSiteURL(%q) = %v, want errSiteURL", tc.raw, err)
}
})
}
}
func TestSiteLabel(t *testing.T) {
for _, tc := range []struct{ url, label, want string }{
{"/", " acme.example ", "acme.example"},
{"https://acme.example/docs", "Acme", "Acme"},
{"https://acme.example/docs", "", "acme.example"},
{"https://acme.example/docs", " ", "acme.example"},
{"http://acme.example:8080/", "", "acme.example:8080"},
{"/", "", "Home"},
{"/home", "", "Home"},
} {
if got := siteLabel(tc.url, tc.label); got != tc.want {
t.Errorf("siteLabel(%q, %q) = %q, want %q", tc.url, tc.label, got, tc.want)
}
}
}
// TestSiteURLPrecedence checks that the --site-url and --site-label options
// override site.yaml the way --base-url overrides base_url.
func TestSiteURLPrecedence(t *testing.T) {
for _, tc := range []struct {
name string
yaml string
opts Options
wantURL string
wantLabel string
wantErr string
}{
{name: "nothing set"},
{name: "yaml url, derived label", yaml: "site_url: https://acme.example:8443/x\n", wantURL: "https://acme.example:8443/x", wantLabel: "acme.example:8443"},
{name: "yaml url and label", yaml: "site_url: /\nsite_label: Yaml\n", wantURL: "/", wantLabel: "Yaml"},
{name: "option url wins", yaml: "site_url: https://yaml.example\n", opts: Options{SiteURL: "/"}, wantURL: "/", wantLabel: "Home"},
{name: "option label wins", yaml: "site_url: /\nsite_label: Yaml\n", opts: Options{SiteLabel: "Opt"}, wantURL: "/", wantLabel: "Opt"},
{name: "option url keeps yaml label", yaml: "site_url: /\nsite_label: Yaml\n", opts: Options{SiteURL: "https://opt.example"}, wantURL: "https://opt.example", wantLabel: "Yaml"},
{name: "options only", opts: Options{SiteURL: "https://opt.example/", SiteLabel: "Opt"}, wantURL: "https://opt.example/", wantLabel: "Opt"},
{name: "option label without url", opts: Options{SiteLabel: "x"}, wantErr: "docsite: --site-label needs --site-url or site.yaml site_url"},
{name: "invalid option url", yaml: "site_url: /\n", opts: Options{SiteURL: "javascript:alert(1)"}, wantErr: "docsite: --site-url: must be an http(s) URL"},
{name: "protocol-relative option url", opts: Options{SiteURL: "//acme.example"}, wantErr: "docsite: --site-url: "},
{name: "two-line option label", opts: Options{SiteURL: "/", SiteLabel: "a\nb"}, wantErr: "docsite: --site-label: must be one non-empty line"},
{name: "blank option label", opts: Options{SiteURL: "/", SiteLabel: " "}, wantErr: "docsite: --site-label: must be one non-empty line"},
} {
t.Run(tc.name, func(t *testing.T) {
root := themeTree(t)
if err := os.WriteFile(filepath.Join(root, "docs", "site.yaml"), []byte(fixtureSite+tc.yaml), 0o644); err != nil {
t.Fatal(err)
}
opts := tc.opts
opts.Root, opts.Commands, opts.Out = root, fixtureCommands, filepath.Join(t.TempDir(), "site")
s, problems, err := load(opts)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("load error = %v, want %q", err, tc.wantErr)
}
return
}
if err != nil || len(problems) > 0 {
t.Fatalf("load: %v %q", err, problemLines(problems))
}
if s.siteURL != tc.wantURL || s.siteLabel != tc.wantLabel {
t.Fatalf("site link = %q %q, want %q %q", s.siteURL, s.siteLabel, tc.wantURL, tc.wantLabel)
}
})
}
}