- 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)
464 lines
16 KiB
Go
464 lines
16 KiB
Go
package docsite
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// writeTree writes files (path relative to root, forward slashes) under a
|
|
// fresh temp dir and returns it.
|
|
func writeTree(t *testing.T, files map[string]string) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for name, body := range files {
|
|
p := filepath.Join(root, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
func problemLines(problems []Problem) []string {
|
|
var out []string
|
|
for _, p := range problems {
|
|
out = append(out, p.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fixtureCommands is the command set the fixture trees are checked with.
|
|
var fixtureCommands = &Commands{Tool: []string{"docs:build", "make:plugin"}, App: []string{"serve", "migrate"}}
|
|
|
|
const fixtureSite = `title: Acme
|
|
description: Acme docs.
|
|
sections:
|
|
- name: setup
|
|
title: Setup
|
|
- name: api
|
|
title: API reference
|
|
`
|
|
|
|
func page(title, section string, order int, body string) string {
|
|
return "---\ntitle: " + title + "\ndescription: " + title + " page.\nsection: " + section +
|
|
"\norder: " + strconv.Itoa(order) + "\n---\n# " + title + "\n\n" + body
|
|
}
|
|
|
|
func TestSlugIDs(t *testing.T) {
|
|
ids := newSlugIDs()
|
|
for _, tc := range []struct{ in, want string }{
|
|
{"Install the CLI", "install-the-cli"},
|
|
{"Install the CLI", "install-the-cli-1"},
|
|
{"Install the CLI", "install-the-cli-2"},
|
|
{"snake_case and dash-case", "snake_case-and-dash-case"},
|
|
{"What's new? (v2.0)", "whats-new-v20"},
|
|
{"`bonfire.Call` usage", "bonfirecall-usage"},
|
|
{"Zażółć gęślą", "zażółć-gęślą"},
|
|
{"!!!", "section"},
|
|
} {
|
|
if got := string(ids.Generate([]byte(tc.in), 0)); got != tc.want {
|
|
t.Errorf("Generate(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
ids.Put([]byte("custom"))
|
|
if got := string(ids.Generate([]byte("Custom"), 0)); got != "custom-1" {
|
|
t.Errorf("after Put, Generate(Custom) = %q, want custom-1", got)
|
|
}
|
|
}
|
|
|
|
func TestReadmeIngestion(t *testing.T) {
|
|
files := map[string]string{
|
|
"docs/site.yaml": fixtureSite,
|
|
"docs/index.md": page("Acme docs", "index", 0, "Read [alpha](../modules/alpha/README.md#usage).\n"),
|
|
"docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nText.\n"),
|
|
"modules/alpha/alpha.go": "package alpha\n",
|
|
"modules/alpha/README.md": "# alpha\n\nAlpha does one thing well.\n\n## Usage\n\n" +
|
|
"See [delta](../delta/README.md#api) and [start](../../docs/setup/start.md).\n",
|
|
"modules/beta/beta.go": "package beta\n",
|
|
"modules/gamma/gamma_test.go": "package gamma\n",
|
|
"modules/delta/delta.go": "package delta\n",
|
|
"modules/delta/README.md": "# delta\n\nDelta does another thing.\n\n## API\n\nText.\n",
|
|
}
|
|
root := writeTree(t, files)
|
|
|
|
pages, problems, err := Pages(Options{Root: root})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "modules/beta: readme: package has Go files but no README.md"
|
|
if got := problemLines(problems); !slices.Equal(got, []string{want}) {
|
|
t.Fatalf("problems = %q, want [%q]", got, want)
|
|
}
|
|
var urls []string
|
|
for _, p := range pages {
|
|
urls = append(urls, p.URL)
|
|
}
|
|
if !slices.Equal(urls, []string{"index", "setup/start", "api/alpha", "api/delta"}) {
|
|
t.Fatalf("reading order = %v", urls)
|
|
}
|
|
alpha := pages[2]
|
|
if alpha.Title != "alpha" || alpha.Description != "Alpha does one thing well." || alpha.Section != "api" ||
|
|
alpha.Module != "alpha" || alpha.Source != "modules/alpha/README.md" {
|
|
t.Fatalf("alpha page = %+v", alpha)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(root, "modules/beta/README.md"), []byte("# beta\n\nBeta.\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
res, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out})
|
|
if err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
|
}
|
|
if res.Pages != 5 {
|
|
t.Fatalf("pages = %d, want 5", res.Pages)
|
|
}
|
|
read := func(name string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(out, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
html := read("api/alpha.html")
|
|
for _, want := range []string{`href="/api/delta.html#api"`, `href="/setup/start.html"`, `<h2 id="usage">Usage<a class="heading-anchor" href="#usage" aria-label="Link to section: Usage">#</a></h2>`} {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("api/alpha.html missing %s", want)
|
|
}
|
|
}
|
|
if strings.Contains(html, "<h1>alpha</h1>\n<p class=\"lead\">Alpha does one thing well.</p>\n<p>Alpha does") {
|
|
t.Error("summary line rendered twice")
|
|
}
|
|
md := read("api/alpha.md")
|
|
if !strings.HasPrefix(md, "# alpha\n\n> Alpha does one thing well.\n\n## Usage\n") ||
|
|
!strings.Contains(md, "(/api/delta.md#api)") || !strings.Contains(md, "(/setup/start.md)") {
|
|
t.Errorf("api/alpha.md = %q", md)
|
|
}
|
|
if idx := read("index.html"); !strings.Contains(idx, `href="/api/alpha.html#usage"`) {
|
|
t.Error("index.html does not link the rewritten module README")
|
|
}
|
|
search := read("search-index.json")
|
|
if !strings.Contains(search, `{"p":2,"a":"usage","h":"Usage","x":"See delta and start."}`) {
|
|
t.Errorf("search index missing the alpha Usage entry: %s", search)
|
|
}
|
|
}
|
|
|
|
const snippetSite = `title: Acme
|
|
description: Acme docs.
|
|
sections:
|
|
- name: setup
|
|
title: Setup
|
|
`
|
|
|
|
const libGo = `package lib
|
|
|
|
// Greeting returns a greeting.
|
|
func Greeting(name string) string {
|
|
return "Hello, " + name
|
|
}
|
|
|
|
const (
|
|
// A is the first.
|
|
A = 1
|
|
B = 2
|
|
)
|
|
`
|
|
|
|
const libTestGo = `package lib
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
func TestGreeting(t *testing.T) {
|
|
// docs:start call
|
|
got := Greeting("x")
|
|
// docs:end call
|
|
if got == "" || check() == "" {
|
|
t.Fatal("empty")
|
|
}
|
|
}
|
|
|
|
func check() string {
|
|
// docs:start called
|
|
return Greeting("y")
|
|
// docs:end called
|
|
}
|
|
|
|
func helper() string {
|
|
// docs:start orphan
|
|
return "x"
|
|
// docs:end orphan
|
|
}
|
|
|
|
func ExampleGreeting() {
|
|
fmt.Println(Greeting("blog"))
|
|
// Output: Hello, blog
|
|
}
|
|
|
|
func ExampleB() {
|
|
_ = Greeting("x")
|
|
}
|
|
`
|
|
|
|
// snippetTree is a repository fixture with Go sources, tests and YAML that
|
|
// src= references can name.
|
|
func snippetTree(t *testing.T, extra map[string]string) string {
|
|
t.Helper()
|
|
files := map[string]string{
|
|
"go.mod": "module example.com/acme\n\ngo 1.27\n",
|
|
"docs/site.yaml": snippetSite,
|
|
"docs/index.md": page("Acme docs", "index", 0, "Hello.\n"),
|
|
"pkg/lib.go": libGo,
|
|
"pkg/lib_test.go": libTestGo,
|
|
"config/app.yaml": "app:\n # docs:start db\n db:\n host: localhost\n # docs:end db\n",
|
|
"nested/go.mod": "module example.com/nested\n",
|
|
"nested/x.go": "package nested\n\n// X is x.\nconst X = 1\n",
|
|
"nested/x_test.go": "package nested\n",
|
|
"notests/a.go": "package notests\n\n// A is a.\nconst A = 1\n",
|
|
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
|
|
}
|
|
for k, v := range extra {
|
|
files[k] = v
|
|
}
|
|
return writeTree(t, files)
|
|
}
|
|
|
|
func TestSnippetForms(t *testing.T) {
|
|
root := snippetTree(t, nil)
|
|
for _, tc := range []struct {
|
|
ref Ref
|
|
want string
|
|
}{
|
|
{Ref{Path: "pkg/lib.go"}, libGo},
|
|
{Ref{Path: "pkg/lib.go", Fragment: "Greeting"}, "// Greeting returns a greeting.\nfunc Greeting(name string) string {\n\treturn \"Hello, \" + name\n}"},
|
|
{Ref{Path: "pkg/lib.go", Fragment: "A"}, "// A is the first.\n\tA = 1"},
|
|
{Ref{Path: "pkg/lib.go", Fragment: "B"}, "B = 2"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "ExampleGreeting"}, "fmt.Println(Greeting(\"blog\"))\n// Output: Hello, blog"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "call"}, "got := Greeting(\"x\")"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "called"}, "return Greeting(\"y\")"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "check"}, "func check() string {\n\t// docs:start called\n\treturn Greeting(\"y\")\n\t// docs:end called\n}"},
|
|
{Ref{Path: "config/app.yaml", Fragment: "db"}, "db:\n host: localhost"},
|
|
} {
|
|
got, err := Extract(root, tc.ref)
|
|
if err != nil {
|
|
t.Errorf("Extract(%s): %v", tc.ref, err)
|
|
continue
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("Extract(%s) = %q, want %q", tc.ref, got, tc.want)
|
|
}
|
|
}
|
|
for _, ref := range []Ref{{Path: "pkg/lib.go", Fragment: "Missing"}, {Path: "pkg/nope.go"}, {Path: "config/app.yaml", Fragment: "nope"}} {
|
|
if _, err := Extract(root, ref); err != errSnippetNotFound {
|
|
t.Errorf("Extract(%s) err = %v, want not found", ref, err)
|
|
}
|
|
}
|
|
if ref, ok := ParseSrc("go src=pkg/lib.go#Greeting title=x"); !ok || ref != (Ref{Path: "pkg/lib.go", Fragment: "Greeting"}) {
|
|
t.Errorf("ParseSrc = %+v %v", ref, ok)
|
|
}
|
|
if _, ok := ParseSrc("go"); ok {
|
|
t.Error("ParseSrc found src= in a bare info string")
|
|
}
|
|
}
|
|
|
|
func TestSnippetConfinement(t *testing.T) {
|
|
outside := filepath.Join(t.TempDir(), "secret.txt")
|
|
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := []struct{ src, want string }{
|
|
{"/etc/passwd", "path must be relative"},
|
|
{"../x.go", "must not leave the repository root"},
|
|
{".env", "dotfile or .env file"},
|
|
{"config/prod.env", "dotfile or .env file"},
|
|
{"nested/x.go#X", "nested module nested/go.mod"},
|
|
{"escape.txt", "outside the repository root"},
|
|
{"notests/a.go#A", "no _test.go files"},
|
|
{"pkg/lib_test.go#ExampleB", "has no // Output: comment"},
|
|
{"pkg/lib_test.go#orphan", "not inside a Test or Example function"},
|
|
{"pkg/lib_test.go#helper", "not inside a Test or Example function"},
|
|
{"pkg/nope.go", "pkg/nope.go not found"},
|
|
}
|
|
var body strings.Builder
|
|
lines := map[string]int{}
|
|
line := 8 // frontmatter (6 lines), "# Snippets", blank
|
|
for _, tc := range cases {
|
|
body.WriteString("```go src=" + tc.src + "\n```\n\n")
|
|
line++
|
|
lines[tc.src] = line
|
|
line += 2
|
|
}
|
|
root := snippetTree(t, map[string]string{
|
|
"docs/setup/snippets.md": page("Snippets", "setup", 20, body.String()),
|
|
"config/prod.env": "SECRET=1\n",
|
|
})
|
|
if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
problems, err := Check(Options{Root: root, Commands: fixtureCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := problemLines(problems)
|
|
if len(got) != len(cases) {
|
|
t.Errorf("got %d problems, want %d:\n%s", len(got), len(cases), strings.Join(got, "\n"))
|
|
}
|
|
for _, tc := range cases {
|
|
prefix := "docs/setup/snippets.md:" + strconv.Itoa(lines[tc.src]) + ": snippet: "
|
|
if !slices.ContainsFunc(got, func(g string) bool { return strings.HasPrefix(g, prefix) && strings.Contains(g, tc.want) }) {
|
|
t.Errorf("src=%s: no problem %q...%q in\n%s", tc.src, prefix, tc.want, strings.Join(got, "\n"))
|
|
}
|
|
}
|
|
if _, _, err := Build(Options{Root: root, Commands: fixtureCommands, Out: filepath.Join(t.TempDir(), "site")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestSyncRewritesDrift(t *testing.T) {
|
|
doc := page("Start", "setup", 10, "Intro.\n\n- In a list:\n\n ```go src=pkg/lib.go#Greeting\n func stale() {}\n ```\n\n"+
|
|
"```yaml src=config/app.yaml#db\ndb:\n host: localhost\n```\n\nOutro.\n")
|
|
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
|
path := filepath.Join(root, "docs/setup/start.md")
|
|
|
|
problems, err := Check(Options{Root: root, Commands: fixtureCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "docs/setup/start.md:13: snippet: body differs from pkg/lib.go#Greeting (run: summer docs:sync)"
|
|
if got := problemLines(problems); !slices.Equal(got, []string{want}) {
|
|
t.Fatalf("problems = %q, want [%q]", got, want)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
if _, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out}); err != nil || len(problems) != 1 {
|
|
t.Fatalf("Build with drift: %v %v", err, problems)
|
|
}
|
|
if _, err := os.Stat(out); err == nil {
|
|
t.Fatal("Build wrote output despite a drifted snippet")
|
|
}
|
|
|
|
res, problems, err := Sync(Options{Root: root})
|
|
if err != nil || len(problems) > 0 {
|
|
t.Fatalf("Sync: %v %v", err, problems)
|
|
}
|
|
if res != (SyncResult{Snippets: 1, Files: 1}) {
|
|
t.Fatalf("Sync = %+v, want 1 snippet in 1 file", res)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantDoc := strings.Replace(doc, " func stale() {}\n",
|
|
" // Greeting returns a greeting.\n func Greeting(name string) string {\n \treturn \"Hello, \" + name\n }\n", 1)
|
|
if string(got) != wantDoc {
|
|
t.Fatalf("synced file =\n%s\nwant\n%s", got, wantDoc)
|
|
}
|
|
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
|
t.Fatalf("second Sync = %+v, %v; want up to date", res, err)
|
|
}
|
|
if problems, err := Check(Options{Root: root, Commands: fixtureCommands}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Check after Sync: %v %q", err, problemLines(problems))
|
|
}
|
|
if _, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build after Sync: %v %v", err, problems)
|
|
}
|
|
md, err := os.ReadFile(filepath.Join(out, "setup/start.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(md), "src=") || !strings.Contains(string(md), " ```go\n") || !strings.Contains(string(md), "```yaml\n") {
|
|
t.Errorf("start.md fences not reduced to the language word:\n%s", md)
|
|
}
|
|
}
|
|
|
|
func TestProblemString(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
p Problem
|
|
want string
|
|
}{
|
|
{Problem{File: "docs/a.md", Line: 3, Rule: "link", Message: "x does not resolve"}, "docs/a.md:3: link: x does not resolve"},
|
|
{Problem{File: "modules/b", Rule: "readme", Message: "no README"}, "modules/b: readme: no README"},
|
|
} {
|
|
if got := tc.p.String(); got != tc.want {
|
|
t.Errorf("String() = %q, want %q", got, tc.want)
|
|
}
|
|
}
|
|
problems := []Problem{{File: "b", Line: 2}, {File: "a", Line: 9}, {File: "b", Line: 1}, {File: "a", Line: 9, Rule: "second"}}
|
|
sortProblems(problems)
|
|
if got := problemLines(problems); !slices.Equal(got, []string{"a:9: : ", "a:9: second: ", "b:1: : ", "b:2: : "}) {
|
|
t.Errorf("sortProblems = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPathHelpers(t *testing.T) {
|
|
sep := string(filepath.Separator)
|
|
for _, tc := range []struct {
|
|
p, dir string
|
|
want bool
|
|
}{
|
|
{"/a/b", "/a/b", true},
|
|
{"/a/b/c", "/a/b", true},
|
|
{"/a/bc", "/a/b", false},
|
|
{"/a", "/a/b", false},
|
|
{"/a/..b", "/a", true},
|
|
{"/x" + sep + "y", "/x", true},
|
|
} {
|
|
if got := within(filepath.FromSlash(tc.p), filepath.FromSlash(tc.dir)); got != tc.want {
|
|
t.Errorf("within(%s, %s) = %v, want %v", tc.p, tc.dir, got, tc.want)
|
|
}
|
|
}
|
|
dir := t.TempDir()
|
|
real := filepath.Join(dir, "real")
|
|
if err := os.Mkdir(real, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(dir, "link")
|
|
if err := os.Symlink(real, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
realResolved, err := filepath.EvalSymlinks(real)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := resolved(filepath.Join(link, "not", "yet")); got != filepath.Join(realResolved, "not", "yet") {
|
|
t.Errorf("resolved(link/not/yet) = %s", got)
|
|
}
|
|
if got := resolved("/no-such-root-dir/x"); got != "/no-such-root-dir/x" {
|
|
t.Errorf("resolved(missing) = %s", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteOutputs(t *testing.T) {
|
|
out := t.TempDir()
|
|
if err := writeOutputs(out, map[string][]byte{"a/b.html": []byte("x"), "c.txt": []byte("y")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, err := os.ReadFile(filepath.Join(out, "a", "b.html")); err != nil || string(got) != "x" {
|
|
t.Fatalf("a/b.html = %q, %v", got, err)
|
|
}
|
|
if err := writeOutputs(out, map[string][]byte{"../escape.html": []byte("x")}); err == nil || !strings.Contains(err.Error(), "escapes") {
|
|
t.Fatalf("an escaping output path = %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(filepath.Dir(out), "escape.html")); err == nil {
|
|
t.Fatal("an output escaped the directory")
|
|
}
|
|
// A file where a directory is needed fails the write.
|
|
if err := writeOutputs(out, map[string][]byte{"c.txt/d.html": []byte("z")}); err == nil {
|
|
t.Fatal("writing under a file succeeded")
|
|
}
|
|
// prepareOut refuses a path it cannot read as a directory.
|
|
file := filepath.Join(out, "c.txt")
|
|
if err := prepareOut(file); err == nil {
|
|
t.Fatal("prepareOut on a file succeeded")
|
|
}
|
|
}
|