Files
summercms/cmd/summer/docs_test.go
Jakub Zych 9d37d56486 feat(11.1-04): add the Services section with a verified Queued jobs page
- docs/services/jobs.md: declaring, registering and dispatching jobs, the
  summer_jobs record, progress, cancellation and workers
- conga ExampleJob plus dispatch and status regions run by TestDocsDispatch
  on the package's Postgres harness (DocsApp in export_docs_test.go)
- concept map links the queued jobs row; services/jobs is a required page
2026-09-30 22:35:22 +02:00

364 lines
10 KiB
Go

package main
import (
"bufio"
"bytes"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/summercms/internal/docsite"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
const repoRoot = "../.."
// TestDocsTree fails with every problem line in the real docs tree.
func TestDocsTree(t *testing.T) {
problems, err := docsite.Check(docsite.Options{Root: repoRoot, Commands: docsCommands()})
if err != nil {
t.Fatal(err)
}
for _, p := range problems {
t.Error(p.String())
}
}
// buildRealTree runs docs:build on the real tree into a temp dir and returns
// the output dir and the command output.
func buildRealTree(t *testing.T) (string, string) {
t.Helper()
out := filepath.Join(t.TempDir(), "site")
var buf bytes.Buffer
root, err := bonfire.NewRoot("summer", toolCommands(), &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"docs:build", "--root", repoRoot, "--out", out})
if err := root.Execute(); err != nil {
t.Fatalf("docs:build: %v\n%s", err, buf.String())
}
return out, buf.String()
}
func TestDocsBuildRealTree(t *testing.T) {
out, stdout := buildRealTree(t)
if !regexp.MustCompile(`docs:build: wrote \d+ pages to `).MatchString(stdout) {
t.Fatalf("output = %q, want a wrote-pages line", stdout)
}
for _, name := range []string{
"index.html", "index.md",
"setup/installation.html", "setup/installation.md",
"llms.txt", "llms-full.txt", "search-index.json",
"assets/site.css", docsite.MarkerFile,
"404.html", "assets/site.js", "assets/search.js", "assets/theme-init.js",
"assets/LICENSE-lucide.txt", "assets/fonts/LICENSE-dm-sans.txt", "assets/fonts/LICENSE-dm-mono.txt",
"assets/fonts/dm-sans-latin-400-normal.woff2", "assets/fonts/dm-sans-latin-600-normal.woff2",
"assets/fonts/dm-sans-latin-ext-400-normal.woff2", "assets/fonts/dm-sans-latin-ext-600-normal.woff2",
"assets/fonts/dm-sans-latin-400-italic.woff2", "assets/fonts/dm-sans-latin-ext-400-italic.woff2",
"assets/fonts/dm-mono-latin-400-normal.woff2", "assets/fonts/dm-mono-latin-ext-400-normal.woff2",
} {
if _, err := os.Stat(filepath.Join(out, name)); err != nil {
t.Errorf("missing %s: %v", name, err)
}
}
}
// requiredPages lists the guide pages the docs must keep. Each content plan
// appends the pages it writes.
var requiredPages = []string{
"index",
"setup/installation",
"setup/coming-from-wintercms",
"architecture/introduction",
"architecture/go-modules-and-workspaces",
"architecture/application-lifecycle",
"architecture/request-lifecycle",
"plugins/registration",
"plugins/scheduling",
"plugins/extending",
"plugins/testing",
"setup/introduction",
"setup/configuration",
"console/introduction",
"console/setup-and-maintenance",
"console/scaffolding",
"console/writing-commands",
"console/utilities",
"services/jobs",
}
// TestDocsRequiredPages asserts that every required page is in the loaded
// tree and is built as both .html and .md on the real tree.
func TestDocsRequiredPages(t *testing.T) {
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot, Commands: docsCommands()})
if err != nil || len(problems) > 0 {
t.Fatalf("Pages: %v %v", err, problems)
}
loaded := map[string]bool{}
for _, p := range pages {
loaded[p.URL] = true
}
out, _ := buildRealTree(t)
for _, url := range requiredPages {
if !loaded[url] {
t.Errorf("page %s is not in the docs tree", url)
}
for _, ext := range []string{".html", ".md"} {
if _, err := os.Stat(filepath.Join(out, filepath.FromSlash(url)+ext)); err != nil {
t.Errorf("page %s has no %s output: %v", url, ext, err)
}
}
}
}
// frameworkModules lists modules/<m> directories that hold a non-test Go
// file, discovered independently of docsite.
func frameworkModules(t *testing.T) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(repoRoot, "modules"))
if err != nil {
t.Fatal(err)
}
var names []string
for _, e := range entries {
if !e.IsDir() {
continue
}
goFiles, err := filepath.Glob(filepath.Join(repoRoot, "modules", e.Name(), "*.go"))
if err != nil {
t.Fatal(err)
}
if slices.ContainsFunc(goFiles, func(f string) bool { return !strings.HasSuffix(f, "_test.go") }) {
names = append(names, e.Name())
}
}
if len(names) == 0 {
t.Fatal("no framework modules found")
}
return names
}
func TestEveryModuleInSidebar(t *testing.T) {
out, _ := buildRealTree(t)
index, err := os.ReadFile(filepath.Join(out, "index.html"))
if err != nil {
t.Fatal(err)
}
start := bytes.Index(index, []byte(`<nav class="sidebar" aria-label="Documentation">`))
end := bytes.Index(index[max(start, 0):], []byte("</nav>"))
if start < 0 || end < 0 {
t.Fatal("index.html has no sidebar nav")
}
sidebar := string(index[start : start+end])
for _, m := range frameworkModules(t) {
if _, err := os.Stat(filepath.Join(out, "api", m+".html")); err != nil {
t.Errorf("module %s has no API page: %v", m, err)
}
if !strings.Contains(sidebar, `href="/api/`+m+`.html"`) {
t.Errorf("sidebar does not link api/%s.html", m)
}
}
}
// TestDocsAIOutputsInSync asserts that the page tree, the .html pages, the
// .md siblings, llms.txt and llms-full.txt all list the same pages in the
// same reading order.
func TestDocsAIOutputsInSync(t *testing.T) {
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot, Commands: docsCommands()})
if err != nil || len(problems) > 0 {
t.Fatalf("Pages: %v %v", err, problems)
}
var want []string
for _, p := range pages {
want = append(want, p.URL)
}
out, _ := buildRealTree(t)
var htmlFiles, mdFiles []string
if err := filepath.WalkDir(out, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(out, p)
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "assets/") {
return nil
}
if rel == "404.html" {
return nil
}
switch filepath.Ext(rel) {
case ".html":
htmlFiles = append(htmlFiles, strings.TrimSuffix(rel, ".html"))
case ".md":
mdFiles = append(mdFiles, strings.TrimSuffix(rel, ".md"))
}
return nil
}); err != nil {
t.Fatal(err)
}
sorted := slices.Sorted(slices.Values(want))
slices.Sort(htmlFiles)
slices.Sort(mdFiles)
if !slices.Equal(htmlFiles, sorted) {
t.Errorf(".html pages = %v, want %v", htmlFiles, sorted)
}
if !slices.Equal(mdFiles, sorted) {
t.Errorf(".md pages = %v, want %v", mdFiles, sorted)
}
llms := readLines(t, filepath.Join(out, "llms.txt"))
if len(llms) == 0 || llms[0] != "# SummerCMS" {
t.Fatalf("llms.txt line 1 = %q, want # SummerCMS", first(llms))
}
if next := nextNonEmpty(llms, 1); next < 0 || !strings.HasPrefix(llms[next], "> ") {
t.Errorf("llms.txt: the line after the H1 must be a > summary")
}
item := regexp.MustCompile(`^- \[[^\]]+\]\(/([^)]+)\.md\): \S`)
var linked []string
for i, line := range llms {
if strings.HasPrefix(line, "## ") {
if next := nextNonEmpty(llms, i+1); next < 0 || !strings.HasPrefix(llms[next], "- [") {
t.Errorf("llms.txt: %q is not followed by a link list", line)
}
}
if m := item.FindStringSubmatch(line); m != nil {
linked = append(linked, m[1])
}
}
if !slices.Equal(linked, want) {
t.Errorf("llms.txt links = %v, want %v", linked, want)
}
var sources []string
for _, line := range readLines(t, filepath.Join(out, "llms-full.txt")) {
if rest, ok := strings.CutPrefix(line, "Source: /"); ok {
sources = append(sources, strings.TrimSuffix(rest, ".html"))
}
}
if !slices.Equal(sources, want) {
t.Errorf("llms-full.txt sources = %v, want %v", sources, want)
}
}
func readLines(t *testing.T, path string) []string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
var lines []string
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 1<<24)
for sc.Scan() {
lines = append(lines, sc.Text())
}
if err := sc.Err(); err != nil {
t.Fatal(err)
}
return lines
}
func nextNonEmpty(lines []string, from int) int {
for i := from; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) != "" {
return i
}
}
return -1
}
func first(lines []string) string {
if len(lines) == 0 {
return ""
}
return lines[0]
}
func TestDocsCommandNames(t *testing.T) {
cmds := docsCommands()
for _, want := range []string{"docs:build", "make:plugin", "migrate:status"} {
if !slices.Contains(cmds.Tool, want) {
t.Errorf("Tool is missing %s: %v", want, cmds.Tool)
}
}
for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health"} {
if !slices.Contains(cmds.App, want) {
t.Errorf("App is missing %s: %v", want, cmds.App)
}
}
}
// generatedConstructor matches a command constructor the generated app main
// appends to its command list.
var generatedConstructor = regexp.MustCompile(`(?:commands :=|append\(commands,)\s*([a-z]+)\.([A-Z][A-Za-z0-9]*)\(app\b`)
// TestDocsCommandsMirrorGeneratedMain keeps docsCommands in step with the
// application main internal/build generates: every command constructor
// written there must also be called in docs.go.
func TestDocsCommandsMirrorGeneratedMain(t *testing.T) {
fset := token.NewFileSet()
buildFile, err := parser.ParseFile(fset, filepath.Join(repoRoot, "internal", "build", "build.go"), nil, 0)
if err != nil {
t.Fatal(err)
}
var generated []string
ast.Inspect(buildFile, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(lit.Value)
if err != nil {
return true
}
for _, m := range generatedConstructor.FindAllStringSubmatch(v, -1) {
generated = append(generated, m[1]+"."+m[2])
}
return true
})
if len(generated) < 5 {
t.Fatalf("found %d constructors in internal/build/build.go (%v), want at least 5", len(generated), generated)
}
docsFile, err := parser.ParseFile(fset, "docs.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var called []string
ast.Inspect(docsFile, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name.Name != "docsCommands" {
return true
}
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if pkg, ok := sel.X.(*ast.Ident); ok {
called = append(called, pkg.Name+"."+sel.Sel.Name)
}
}
return true
})
return false
})
for _, c := range generated {
if !slices.Contains(called, c) {
t.Errorf("the generated main calls %s but docsCommands does not", c)
}
}
}