- 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)
323 lines
11 KiB
Go
323 lines
11 KiB
Go
package docsite
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const genericGo = `package gen
|
|
|
|
// Map applies f to every element.
|
|
func Map[T, U any](in []T, f func(T) U) []U {
|
|
out := make([]U, 0, len(in))
|
|
for _, v := range in {
|
|
out = append(out, f(v))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Box holds one value.
|
|
type Box[T any] struct{ v T }
|
|
|
|
// Get returns the value.
|
|
func (b *Box[T]) Get() T { return b.v }
|
|
|
|
// Pair holds a key and a value.
|
|
type Pair[K comparable, V any] struct {
|
|
K K
|
|
V V
|
|
}
|
|
|
|
// Key returns the key.
|
|
func (p Pair[K, V]) Key() K { return p.K }
|
|
|
|
// Paren has a parenthesised receiver.
|
|
func (b (*Box[T])) Paren() {}
|
|
|
|
var (
|
|
// Limit is documented in the group.
|
|
Limit = 10
|
|
Other = 2
|
|
)
|
|
|
|
// Single is an ungrouped var.
|
|
var Single = 1
|
|
|
|
type (
|
|
// ID is a grouped type.
|
|
ID int
|
|
)
|
|
`
|
|
|
|
const genericTestGo = `package gen
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
func TestMap(t *testing.T) {
|
|
// docs:start map
|
|
got := Map([]int{1, 2}, func(v int) string {
|
|
return fmt.Sprint(v)
|
|
})
|
|
// docs:end map
|
|
_ = viaVar
|
|
if len(got) != 2 || (fixture{name: "x"}).name == "" {
|
|
t.Fatal(got)
|
|
}
|
|
// docs:start map
|
|
duplicate := 1
|
|
// docs:end map
|
|
_ = duplicate
|
|
}
|
|
|
|
// fixture is referenced by a test, so it counts as run.
|
|
type fixture struct{ name string }
|
|
|
|
// indirect is only reached through a package-level var: the run check is
|
|
// conservative and does not follow var initialisers.
|
|
type indirect struct{}
|
|
|
|
var viaVar = indirect{}
|
|
|
|
func ExampleBox_Get() {
|
|
b := &Box[string]{v: "boxed"}
|
|
if b != nil {
|
|
fmt.Println(b.Get())
|
|
}
|
|
// Output: boxed
|
|
}
|
|
|
|
func ExamplePair_Key() {
|
|
fmt.Println(Pair[string, int]{K: "k"}.Key())
|
|
// Output:
|
|
// k
|
|
}
|
|
|
|
func ExampleSingle() {
|
|
_ = Single
|
|
// Output:
|
|
}
|
|
`
|
|
|
|
func genericTree(t *testing.T) string {
|
|
t.Helper()
|
|
return writeTree(t, map[string]string{
|
|
"go.mod": "module example.com/gen\n\ngo 1.27\n",
|
|
"gen/gen.go": genericGo,
|
|
"gen/gen_test.go": genericTestGo,
|
|
"config/app.yaml": "a:\n # docs:start keys\n b: 1\n\n c: 2\n # docs:end keys\n",
|
|
"config/tabs.go.txt": "x",
|
|
"docs/site.yaml": snippetSite,
|
|
"docs/index.md": page("Acme docs", "index", 0, "Hello.\n"),
|
|
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
|
|
})
|
|
}
|
|
|
|
func TestSnippetGenericsAndGroups(t *testing.T) {
|
|
root := genericTree(t)
|
|
for _, tc := range []struct {
|
|
ref Ref
|
|
want string
|
|
}{
|
|
{Ref{"gen/gen.go", "Map"}, "// Map applies f to every element.\nfunc Map[T, U any](in []T, f func(T) U) []U {\n\tout := make([]U, 0, len(in))\n\tfor _, v := range in {\n\t\tout = append(out, f(v))\n\t}\n\treturn out\n}"},
|
|
{Ref{"gen/gen.go", "Box"}, "// Box holds one value.\ntype Box[T any] struct{ v T }"},
|
|
{Ref{"gen/gen.go", "Box.Get"}, "// Get returns the value.\nfunc (b *Box[T]) Get() T { return b.v }"},
|
|
{Ref{"gen/gen.go", "Pair.Key"}, "// Key returns the key.\nfunc (p Pair[K, V]) Key() K { return p.K }"},
|
|
{Ref{"gen/gen.go", "Box.Paren"}, "// Paren has a parenthesised receiver.\nfunc (b (*Box[T])) Paren() {}"},
|
|
{Ref{"gen/gen.go", "Limit"}, "// Limit is documented in the group.\n\tLimit = 10"},
|
|
{Ref{"gen/gen.go", "Other"}, "Other = 2"},
|
|
{Ref{"gen/gen.go", "Single"}, "// Single is an ungrouped var.\nvar Single = 1"},
|
|
{Ref{"gen/gen.go", "ID"}, "// ID is a grouped type.\n\tID int"},
|
|
{Ref{"gen/gen_test.go", "ExampleBox_Get"}, "b := &Box[string]{v: \"boxed\"}\nif b != nil {\n\tfmt.Println(b.Get())\n}\n// Output: boxed"},
|
|
{Ref{"gen/gen_test.go", "ExamplePair_Key"}, "fmt.Println(Pair[string, int]{K: \"k\"}.Key())\n// Output:\n// k"},
|
|
{Ref{"gen/gen_test.go", "ExampleSingle"}, "_ = Single\n// Output:"},
|
|
{Ref{"gen/gen_test.go", "fixture"}, "// fixture is referenced by a test, so it counts as run.\ntype fixture struct{ name string }"},
|
|
{Ref{"gen/gen_test.go", "viaVar"}, "var viaVar = indirect{}"},
|
|
// The first of two same-named regions wins.
|
|
{Ref{"gen/gen_test.go", "map"}, "got := Map([]int{1, 2}, func(v int) string {\n\treturn fmt.Sprint(v)\n})"},
|
|
// A YAML region keeps its inner blank line and is dedented.
|
|
{Ref{"config/app.yaml", "keys"}, "b: 1\n\nc: 2"},
|
|
} {
|
|
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) =\n%q\nwant\n%q", tc.ref, got, tc.want)
|
|
}
|
|
}
|
|
if _, err := Extract(root, Ref{"gen/gen_test.go", "indirect"}); err == nil || err.Error() != notRunMessage {
|
|
t.Errorf("Extract(indirect) = %v, want the not-run refusal", err)
|
|
}
|
|
// A fragment on a non-Go file that is not a region is not found.
|
|
if _, err := Extract(root, Ref{"config/app.yaml", "Map"}); err != errSnippetNotFound {
|
|
t.Errorf("YAML ident err = %v", err)
|
|
}
|
|
// A missing root is an error, not a problem.
|
|
if _, err := Extract(filepath.Join(root, "missing"), Ref{Path: "gen/gen.go"}); err == nil || err == errSnippetNotFound {
|
|
t.Errorf("Extract with a missing root = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDedent(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in, want []string
|
|
}{
|
|
{[]string{"", "\t\ta", "\t\t\tb", "", "\t\tc", " "}, []string{"a", "\tb", "", "c"}},
|
|
{[]string{" x", " y"}, []string{" x", "y"}},
|
|
{[]string{"\t x", "\t\ty"}, []string{" x", "\ty"}},
|
|
{[]string{" ", ""}, []string{}},
|
|
{nil, []string{}},
|
|
} {
|
|
if got := dedent(tc.in); !slices.Equal(got, tc.want) {
|
|
t.Errorf("dedent(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSnippetRefPathRules(t *testing.T) {
|
|
for p, want := range map[string]string{
|
|
"": "src= has no path",
|
|
"/abs.go": "must be relative",
|
|
`\abs.go`: "must be relative",
|
|
"C:/x.go": "must be relative",
|
|
"c:x.go": "must be relative",
|
|
`a\b.go`: "must use forward slashes",
|
|
"a/../b.go": "must not leave the repository root",
|
|
"a/.git/x": "dotfile or .env file",
|
|
"a/prod.env": "dotfile or .env file",
|
|
"./a.go": "dotfile or .env file",
|
|
"a//b.go": "must be clean",
|
|
"a/b/": "must be clean",
|
|
"a/b.go": "",
|
|
"docs/x.md": "",
|
|
"environment": "",
|
|
"a.environ.go": "",
|
|
} {
|
|
err := checkRefPath(p)
|
|
if (want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), want)) {
|
|
t.Errorf("checkRefPath(%q) = %v, want %q", p, err, want)
|
|
}
|
|
}
|
|
for info, want := range map[string]Ref{
|
|
"go src=a.go#X": {"a.go", "X"},
|
|
"yaml src=b.yaml": {"b.yaml", ""},
|
|
"go title=x src=c.go": {"c.go", ""},
|
|
"go src=d.go#A#B": {"d.go", "A#B"},
|
|
} {
|
|
if got, ok := ParseSrc(info); !ok || got != want {
|
|
t.Errorf("ParseSrc(%q) = %+v, %v", info, got, ok)
|
|
}
|
|
}
|
|
if (Ref{Path: "a.go"}).String() != "a.go" || (Ref{"a.go", "X"}).String() != "a.go#X" {
|
|
t.Error("Ref.String")
|
|
}
|
|
}
|
|
|
|
func TestSnippetSymlinkInsideRoot(t *testing.T) {
|
|
root := genericTree(t)
|
|
if err := os.Symlink(filepath.Join(root, "config", "app.yaml"), filepath.Join(root, "config", "alias.yaml")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := Extract(root, Ref{"config/alias.yaml", "keys"})
|
|
if err != nil || got != "b: 1\n\nc: 2" {
|
|
t.Fatalf("Extract through an in-root symlink = %q, %v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestSnippetTrailingNewlines(t *testing.T) {
|
|
// A fence body with or without trailing blank lines matches a source
|
|
// ending in newlines; any other difference is drift.
|
|
body := "```yaml src=config/app.yaml#keys\nb: 1\n\nc: 2\n\n\n```\n\n" +
|
|
"```text src=config/whole.txt\nline\n```\n\n" +
|
|
" ```text src=config/whole.txt\n line\n ```\n"
|
|
root := snippetTree(t, map[string]string{
|
|
"config/app.yaml": "a:\n # docs:start keys\n b: 1\n\n c: 2\n # docs:end keys\n",
|
|
"config/whole.txt": "line\n\n\n",
|
|
"docs/setup/start.md": page("Start", "setup", 10, body),
|
|
})
|
|
if problems, err := Check(Options{Root: root, Commands: fixtureCommands}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Check: %v %q", err, problemLines(problems))
|
|
}
|
|
}
|
|
|
|
func TestSyncPreservesAndReports(t *testing.T) {
|
|
doc := page("Start", "setup", 10, "Before.\n\n```go src=pkg/lib.go#Greeting\nold one\n```\n\nMiddle `x`.\n\n"+
|
|
"```go src=pkg/lib_test.go#ExampleGreeting\nold two\n```\n\n```go src=pkg/lib.go#Greeting\n// Greeting returns a greeting.\n"+
|
|
"func Greeting(name string) string {\n\treturn \"Hello, \" + name\n}\n```\n\nAfter.")
|
|
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
|
path := filepath.Join(root, "docs/setup/start.md")
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, problems, err := Sync(Options{Root: root})
|
|
if err != nil || len(problems) > 0 || res != (SyncResult{Snippets: 2, Files: 1}) {
|
|
t.Fatalf("Sync = %+v %q %v", res, problemLines(problems), err)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := strings.Replace(strings.Replace(doc, "old one", "// Greeting returns a greeting.\nfunc Greeting(name string) string {\n\treturn \"Hello, \" + name\n}", 1),
|
|
"old two", "fmt.Println(Greeting(\"blog\"))\n// Output: Hello, blog", 1)
|
|
if string(got) != want {
|
|
t.Fatalf("synced =\n%s\nwant\n%s", got, want)
|
|
}
|
|
if st, err := os.Stat(path); err != nil || st.Mode().Perm() != 0o600 {
|
|
t.Fatalf("mode after Sync = %v, %v; want 0600", st.Mode().Perm(), err)
|
|
}
|
|
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
|
t.Fatalf("second Sync = %+v, %v", res, err)
|
|
}
|
|
|
|
// One broken reference anywhere means Sync writes nothing at all.
|
|
other := page("Other", "setup", 20, "```go src=pkg/lib.go#Greeting\nstale\n```\n")
|
|
broken := page("Broken", "setup", 30, "```go src=pkg/lib.go#Missing\n```\n")
|
|
root = snippetTree(t, map[string]string{"docs/setup/other.md": other, "docs/setup/broken.md": broken})
|
|
res, problems, err = Sync(Options{Root: root})
|
|
if err != nil || res != (SyncResult{}) {
|
|
t.Fatalf("Sync with a broken ref = %+v, %v", res, err)
|
|
}
|
|
assertProblems(t, problemLines(problems), []string{"docs/setup/broken.md:9: snippet: pkg/lib.go#Missing not found"})
|
|
if got, _ := os.ReadFile(filepath.Join(root, "docs/setup/other.md")); string(got) != other {
|
|
t.Fatal("Sync wrote a file although another reference was broken")
|
|
}
|
|
if _, _, err := Sync(Options{Root: root, Src: filepath.Join(root, "missing")}); err == nil {
|
|
t.Fatal("Sync on a missing source directory returned no error")
|
|
}
|
|
}
|
|
|
|
func TestSnippetTestGraph(t *testing.T) {
|
|
root := writeTree(t, map[string]string{
|
|
"p/p.go": "package p\n",
|
|
"p/a_test.go": "package p\n\nimport \"testing\"\n\nfunc TestA(t *testing.T) { helperOne() }\n\nfunc helperOne() { helperTwo() }\n",
|
|
"p/b_test.go": "package p\n\nfunc helperTwo() {\n\t// docs:start deep\n\t_ = 1\n\t// docs:end deep\n}\n\nfunc (s *suite) run() {}\n\ntype suite struct{}\n\nfunc lonely() {}\n",
|
|
})
|
|
for _, tc := range []struct {
|
|
ref Ref
|
|
want string
|
|
}{
|
|
{Ref{"p/b_test.go", "deep"}, ""},
|
|
{Ref{"p/b_test.go", "helperTwo"}, ""},
|
|
{Ref{"p/b_test.go", "lonely"}, notRunMessage},
|
|
{Ref{"p/b_test.go", "suite.run"}, notRunMessage},
|
|
{Ref{"p/b_test.go", "suite"}, notRunMessage},
|
|
} {
|
|
_, err := Extract(root, tc.ref)
|
|
if (tc.want == "") != (err == nil) || (err != nil && err.Error() != tc.want) {
|
|
t.Errorf("Extract(%s) = %v, want %q", tc.ref, err, tc.want)
|
|
}
|
|
}
|
|
// A test file that does not parse is reported, not skipped.
|
|
writeFile(t, root, "p/c_test.go", "package p\n\nfunc {\n")
|
|
if _, err := Extract(root, Ref{"p/b_test.go", "deep"}); err == nil || !strings.Contains(err.Error(), "cannot parse c_test.go") {
|
|
t.Errorf("Extract with an unparsable sibling test = %v", err)
|
|
}
|
|
}
|