- 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)
313 lines
13 KiB
Go
313 lines
13 KiB
Go
package docsite
|
|
|
|
import (
|
|
"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`},
|
|
} {
|
|
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)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|