test(11.1-06): branch-level tests for every docsite stage (94.8% coverage)
- 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)
This commit is contained in:
298
internal/docsite/emit_test.go
Normal file
298
internal/docsite/emit_test.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const emitSite = `title: Acme
|
||||
description: "Acme is a test site."
|
||||
llms_notes:
|
||||
- "Note one."
|
||||
- "Note two."
|
||||
sections:
|
||||
- name: setup
|
||||
title: Setup
|
||||
- name: guides
|
||||
title: Guides
|
||||
- name: api
|
||||
title: API reference
|
||||
`
|
||||
|
||||
// emitTree is a fixture with an index, a page in each guide section and one
|
||||
// module README.
|
||||
func emitTree(t *testing.T, site string, extra map[string]string) string {
|
||||
t.Helper()
|
||||
files := map[string]string{
|
||||
"docs/site.yaml": site,
|
||||
"docs/index.md": page("Acme docs", "index", 0, "Welcome.\n"),
|
||||
"docs/setup/install.md": page("Install", "setup", 10, "## Get it\n\nRun it.\n\nSee [usage](../guides/usage.md#call-it).\n"),
|
||||
"docs/guides/usage.md": page("Usage", "guides", 10, "## Call it\n\n```sh src=config/run.sh\nrun\n```\n"),
|
||||
"config/run.sh": "run\n",
|
||||
"modules/alpha/alpha.go": "package alpha\n",
|
||||
"modules/alpha/README.md": "# alpha\n\nAlpha does one thing.\n\n## Usage\n\n" +
|
||||
"Read [install](../../docs/setup/install.md).\n",
|
||||
}
|
||||
for k, v := range extra {
|
||||
files[k] = v
|
||||
}
|
||||
return writeTree(t, files)
|
||||
}
|
||||
|
||||
func buildEmit(t *testing.T, opts Options) string {
|
||||
t.Helper()
|
||||
if opts.Out == "" {
|
||||
opts.Out = filepath.Join(t.TempDir(), "site")
|
||||
}
|
||||
opts.Commands = fixtureCommands
|
||||
if _, problems, err := Build(opts); err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
||||
}
|
||||
return opts.Out
|
||||
}
|
||||
|
||||
func TestLLMSTxtShape(t *testing.T) {
|
||||
out := buildEmit(t, Options{Root: emitTree(t, emitSite, nil)})
|
||||
want := `# Acme
|
||||
|
||||
> Acme is a test site.
|
||||
|
||||
- Note one.
|
||||
- Note two.
|
||||
|
||||
## Overview
|
||||
|
||||
- [Acme docs](/index.md): Acme docs page.
|
||||
|
||||
## Setup
|
||||
|
||||
- [Install](/setup/install.md): Install page.
|
||||
|
||||
## Guides
|
||||
|
||||
- [Usage](/guides/usage.md): Usage page.
|
||||
|
||||
## API reference
|
||||
|
||||
- [alpha](/api/alpha.md): Alpha does one thing.
|
||||
`
|
||||
if got := readOut(t, out, "llms.txt"); got != want {
|
||||
t.Fatalf("llms.txt =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
|
||||
// Without notes there is no notes list.
|
||||
noNotes := strings.Replace(emitSite, "llms_notes:\n - \"Note one.\"\n - \"Note two.\"\n", "", 1)
|
||||
out = buildEmit(t, Options{Root: emitTree(t, noNotes, nil)})
|
||||
if got := readOut(t, out, "llms.txt"); !strings.HasPrefix(got, "# Acme\n\n> Acme is a test site.\n\n## Overview\n") {
|
||||
t.Fatalf("llms.txt without notes =\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMSFullBlocks(t *testing.T) {
|
||||
out := buildEmit(t, Options{Root: emitTree(t, emitSite, nil)})
|
||||
want := `# Acme docs
|
||||
Source: /index.html
|
||||
|
||||
Acme docs page.
|
||||
|
||||
Welcome.
|
||||
|
||||
# Install
|
||||
Source: /setup/install.html
|
||||
|
||||
Install page.
|
||||
|
||||
## Get it
|
||||
|
||||
Run it.
|
||||
|
||||
See [usage](/guides/usage.md#call-it).
|
||||
|
||||
# Usage
|
||||
Source: /guides/usage.html
|
||||
|
||||
Usage page.
|
||||
|
||||
## Call it
|
||||
|
||||
` + "```sh\nrun\n```" + `
|
||||
|
||||
# alpha
|
||||
Source: /api/alpha.html
|
||||
|
||||
Alpha does one thing.
|
||||
|
||||
## Usage
|
||||
|
||||
Read [install](/setup/install.md).
|
||||
`
|
||||
if got := readOut(t, out, "llms-full.txt"); got != want {
|
||||
t.Fatalf("llms-full.txt =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownSiblings(t *testing.T) {
|
||||
out := buildEmit(t, Options{Root: emitTree(t, emitSite, nil)})
|
||||
for name, want := range map[string]string{
|
||||
"setup/install.md": "# Install\n\n> Install page.\n\n## Get it\n\nRun it.\n\nSee [usage](/guides/usage.md#call-it).\n",
|
||||
"guides/usage.md": "# Usage\n\n> Usage page.\n\n## Call it\n\n```sh\nrun\n```\n",
|
||||
"api/alpha.md": "# alpha\n\n> Alpha does one thing.\n\n## Usage\n\nRead [install](/setup/install.md).\n",
|
||||
"index.md": "# Acme docs\n\n> Acme docs page.\n\nWelcome.\n",
|
||||
} {
|
||||
if got := readOut(t, out, name); got != want {
|
||||
t.Errorf("%s =\n%q\nwant\n%q", name, got, want)
|
||||
}
|
||||
}
|
||||
// A body that is only its title leaves a single newline.
|
||||
p := &Page{Title: "T", Description: "D.", Body: []byte("# T")}
|
||||
if got := string(pageMarkdown(p, markdownBody(p, nil))); got != "# T\n\n> D.\n\n\n" {
|
||||
t.Fatalf("title-only page = %q", got)
|
||||
}
|
||||
p.Body = []byte("No H1 here.\n\n\n")
|
||||
if got := markdownBody(p, nil); got != "No H1 here.\n" {
|
||||
t.Fatalf("markdownBody without H1 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIndexSchema(t *testing.T) {
|
||||
long := strings.Repeat("word ", 100)
|
||||
root := emitTree(t, emitSite, map[string]string{
|
||||
"docs/guides/long.md": page("Long", "guides", 20, "## Big section\n\n"+long+"\n\n### Nested\n\nNested text.\n\n## Small\n\nTiny.\n"),
|
||||
})
|
||||
out := buildEmit(t, Options{Root: root})
|
||||
var idx struct {
|
||||
Pages []map[string]string `json:"p"`
|
||||
E []map[string]any `json:"e"`
|
||||
}
|
||||
raw := readOut(t, out, "search-index.json")
|
||||
if !strings.HasSuffix(raw, "}\n") {
|
||||
t.Errorf("search-index.json does not end in a newline")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(idx.Pages) != 5 {
|
||||
t.Fatalf("pages = %v", idx.Pages)
|
||||
}
|
||||
for _, p := range idx.Pages {
|
||||
if len(p) != 3 || p["u"] == "" || p["t"] == "" || p["s"] == "" {
|
||||
t.Errorf("page entry %v does not have exactly u, t and s", p)
|
||||
}
|
||||
}
|
||||
if idx.Pages[0]["s"] != "Acme" || idx.Pages[1]["s"] != "Setup" || idx.Pages[4]["s"] != "API reference" {
|
||||
t.Errorf("section labels = %v", idx.Pages)
|
||||
}
|
||||
var big map[string]any
|
||||
for _, e := range idx.E {
|
||||
if len(e) != 4 {
|
||||
t.Errorf("entry %v does not have exactly p, a, h and x", e)
|
||||
}
|
||||
if e["h"] == "Nested" {
|
||||
t.Error("an H3 got its own search entry")
|
||||
}
|
||||
if e["a"] == "big-section" {
|
||||
big = e
|
||||
}
|
||||
}
|
||||
if big == nil {
|
||||
t.Fatalf("no entry for the Big section: %v", idx.E)
|
||||
}
|
||||
x := big["x"].(string)
|
||||
if n := utf8.RuneCountInString(x); n > searchTextMax || n < searchTextMax-5 {
|
||||
t.Errorf("excerpt has %d runes, want at most %d and close to it", n, searchTextMax)
|
||||
}
|
||||
if strings.Contains(x, "Tiny") {
|
||||
t.Error("the excerpt ran into the next H2")
|
||||
}
|
||||
if int(big["p"].(float64)) != 3 || big["h"] != "Big section" {
|
||||
t.Errorf("big entry = %v", big)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLPrefixing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, siteBase, optBase, prefix string
|
||||
}{
|
||||
{"empty", "", "", "/"},
|
||||
{"path prefix from site.yaml", "/docs/", "", "/docs/"},
|
||||
{"absolute URL from site.yaml", "https://docs.example/v1", "", "https://docs.example/v1/"},
|
||||
{"option overrides site.yaml", "/ignored", "https://cdn.example/", "https://cdn.example/"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
site := emitSite
|
||||
if tc.siteBase != "" {
|
||||
site += "base_url: \"" + tc.siteBase + "\"\n"
|
||||
}
|
||||
out := buildEmit(t, Options{Root: emitTree(t, site, nil), BaseURL: tc.optBase})
|
||||
html := readOut(t, out, "setup/install.html")
|
||||
for _, want := range []string{
|
||||
`href="` + tc.prefix + `assets/site.css"`,
|
||||
`href="` + tc.prefix + `guides/usage.html#call-it"`,
|
||||
`href="` + tc.prefix + `setup/install.md"`,
|
||||
`href="` + tc.prefix + `llms.txt"`,
|
||||
`href="` + tc.prefix + `index.html"`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("setup/install.html missing %s", want)
|
||||
}
|
||||
}
|
||||
if llms := readOut(t, out, "llms.txt"); !strings.Contains(llms, "]("+tc.prefix+"setup/install.md)") {
|
||||
t.Errorf("llms.txt links are not prefixed with %s", tc.prefix)
|
||||
}
|
||||
if full := readOut(t, out, "llms-full.txt"); !strings.Contains(full, "Source: "+tc.prefix+"setup/install.html") {
|
||||
t.Errorf("llms-full.txt sources are not prefixed with %s", tc.prefix)
|
||||
}
|
||||
if idx := readOut(t, out, "search-index.json"); !strings.Contains(idx, `"u":"`+tc.prefix+`setup/install.html"`) {
|
||||
t.Errorf("search-index.json URLs are not prefixed with %s", tc.prefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOCThreshold(t *testing.T) {
|
||||
root := emitTree(t, emitSite, map[string]string{
|
||||
"docs/guides/zero.md": page("Zero", "guides", 20, "No headings.\n"),
|
||||
"docs/guides/one.md": page("One", "guides", 30, "## Only\n\n#### Deep\n\nText.\n"),
|
||||
"docs/guides/two.md": page("Two", "guides", 40, "## First\n\n### Second\n\nText.\n"),
|
||||
})
|
||||
out := buildEmit(t, Options{Root: root})
|
||||
for name, want := range map[string]bool{"guides/zero.html": false, "guides/one.html": false, "guides/two.html": true} {
|
||||
html := readOut(t, out, name)
|
||||
if got := strings.Contains(html, `<aside class="toc"`) && strings.Contains(html, `<details class="toc-inline">`); got != want {
|
||||
t.Errorf("%s has a TOC = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
two := readOut(t, out, "guides/two.html")
|
||||
for _, want := range []string{`href="#first"`, `href="#second"`, `class="toc-h3"`} {
|
||||
if !strings.Contains(two, want) {
|
||||
t.Errorf("guides/two.html TOC missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAndSourceURLs(t *testing.T) {
|
||||
site := emitSite + "edit_url: \"https://forge.example/edit/{path}\"\nsource_url: \"https://forge.example/src/{path}\"\n"
|
||||
out := buildEmit(t, Options{Root: emitTree(t, site, nil)})
|
||||
usage := readOut(t, out, "guides/usage.html")
|
||||
for _, want := range []string{
|
||||
`href="https://forge.example/edit/docs/guides/usage.md"`,
|
||||
`<figcaption><a href="https://forge.example/src/config/run.sh">config/run.sh</a></figcaption>`,
|
||||
} {
|
||||
if !strings.Contains(usage, want) {
|
||||
t.Errorf("guides/usage.html missing %s", want)
|
||||
}
|
||||
}
|
||||
if alpha := readOut(t, out, "api/alpha.html"); !strings.Contains(alpha, `href="https://forge.example/edit/modules/alpha/README.md"`) {
|
||||
t.Error("api/alpha.html has no edit link to its README")
|
||||
}
|
||||
|
||||
// Without the URLs there is no edit link and the caption is plain text.
|
||||
out = buildEmit(t, Options{Root: emitTree(t, emitSite, nil)})
|
||||
usage = readOut(t, out, "guides/usage.html")
|
||||
if strings.Contains(usage, "forge.example") || !strings.Contains(usage, "<figcaption>config/run.sh</figcaption>") {
|
||||
t.Errorf("guides/usage.html without edit/source URLs:\n%s", usage)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user