Files
summercms/internal/docsite/emit_test.go
Jakub Zych 28afd4d197 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)
2026-10-01 00:05:12 +02:00

299 lines
9.3 KiB
Go

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)
}
}