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) } } } // TestDocsBuildSiteFlags checks that --site-url and --site-label reach the // header and that an unsafe --site-url is refused. func TestDocsBuildSiteFlags(t *testing.T) { 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, "--site-url", "/", "--site-label", "example.org"}) if err := root.Execute(); err != nil { t.Fatalf("docs:build: %v\n%s", err, buf.String()) } index, err := os.ReadFile(filepath.Join(out, "index.html")) if err != nil { t.Fatal(err) } for _, want := range []string{`class="site-link" href="/"`, "example.org"} { if !bytes.Contains(index, []byte(want)) { t.Errorf("index.html lacks %s", want) } } buf.Reset() root, err = bonfire.NewRoot("summer", toolCommands(), &buf) if err != nil { t.Fatal(err) } root.SetArgs([]string{"docs:build", "--root", repoRoot, "--out", filepath.Join(t.TempDir(), "bad"), "--site-url", "javascript:alert(1)"}) if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--site-url") { t.Fatalf("docs:build --site-url javascript:alert(1): err = %v, want a --site-url error", 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", "database/models", "database/migrations", "database/queries-and-pagination", "database/relations", "database/casts-and-validation", "database/attachments", "database/transactions", "services/configuration", "services/events", "services/routing", "services/rate-limiting", "services/authentication", "services/oauth-server", "services/mail", "services/localization", "backend/admin-controllers", "backend/forms", "backend/lists-and-filters", "backend/relation-manager", "backend/users-and-permissions", "backend/settings", "backend/partials-and-widgets", "backend/admin-spa", "services/storage", "services/outbound-http", "services/realtime", "services/push", "services/search", "services/parity-testing", "services/frontend-and-ajax", "setup/porting-a-plugin", } // sectionOrder is the D-08 sidebar order: WinterCMS's documentation order, // with the API reference last. var sectionOrder = []string{"setup", "architecture", "plugins", "backend", "database", "services", "console", "api"} // TestDocsRequiredPages asserts that every required page is in the loaded // tree and is built as both .html and .md on the real tree, and that // site.yaml lists the sections in sectionOrder. func TestDocsRequiredPages(t *testing.T) { raw, err := os.ReadFile(filepath.Join(repoRoot, "docs", "site.yaml")) if err != nil { t.Fatal(err) } var sections []string for _, m := range regexp.MustCompile(`(?m)^ - name: (\S+)$`).FindAllStringSubmatch(string(raw), -1) { sections = append(sections, m[1]) } if !slices.Equal(sections, sectionOrder) { t.Errorf("site.yaml sections = %v, want %v", sections, sectionOrder) } 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/ 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(`")) 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) } } } // cleanDocsFixture is the internal/docsite fixture that passes every check. const cleanDocsFixture = "../../internal/docsite/testdata/clean" // scratchDocsRoot copies the clean docs fixture into a temp dir. func scratchDocsRoot(t *testing.T) string { t.Helper() root := t.TempDir() err := filepath.WalkDir(cleanDocsFixture, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } rel, _ := filepath.Rel(cleanDocsFixture, p) target := filepath.Join(root, rel) if d.IsDir() { return os.MkdirAll(target, 0o755) } data, err := os.ReadFile(p) if err != nil { return err } return os.WriteFile(target, data, 0o644) }) if err != nil { t.Fatal(err) } return root } // runSummer runs the summer tool with args and returns its output. func runSummer(t *testing.T, args ...string) (string, error) { t.Helper() var buf bytes.Buffer root, err := bonfire.NewRoot("summer", toolCommands(), &buf) if err != nil { t.Fatal(err) } root.SetArgs(args) err = root.Execute() return buf.String(), err } func appendDoc(t *testing.T, root, name, text string) { t.Helper() f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(name)), os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatal(err) } defer f.Close() if _, err := f.WriteString(text); err != nil { t.Fatal(err) } } func TestDocsBuildCheckOutput(t *testing.T) { root := scratchDocsRoot(t) out, err := runSummer(t, "docs:build", "--check", "--root", root) if err != nil || out != "docs:build: no problems found\n" { t.Fatalf("docs:build --check on the clean fixture = %q, %v", out, err) } appendDoc(t, root, "docs/extras/faq.md", "\nSee `demo.Missing` and `summer no:such`.\n") site := filepath.Join(t.TempDir(), "site") for _, args := range [][]string{ {"docs:build", "--check", "--root", root}, {"docs:build", "--root", root, "--out", site}, } { out, err := runSummer(t, args...) if err == nil || err.Error() != "docs:build failed" { t.Fatalf("%v = %v, want docs:build failed", args, err) } want := "docs/extras/faq.md:17: identifier: demo.Missing does not exist in modules/demo\n" + "docs/extras/faq.md:17: command: \"no:such\" is not a summer or application command\n" + "docs:build: 2 problems, nothing written\n" if out != want { t.Fatalf("%v output =\n%s\nwant\n%s", args, out, want) } } if _, err := os.Stat(site); err == nil { t.Fatal("docs:build wrote output despite problems") } // A refused --out is an error without problem lines. if _, err := runSummer(t, "docs:build", "--root", root, "--out", filepath.Join(root, "docs", "site")); err == nil || !strings.Contains(err.Error(), "--out must not be inside --src") { t.Fatalf("docs:build --out inside docs = %v", err) } // A missing site.yaml is an error for --check too. if _, err := runSummer(t, "docs:build", "--check", "--root", t.TempDir()); err == nil || !strings.Contains(err.Error(), "read site config") { t.Fatalf("docs:build --check without site.yaml = %v", err) } // --src and --base-url are honoured. clean := scratchDocsRoot(t) out, err = runSummer(t, "docs:build", "--root", clean, "--src", filepath.Join(clean, "docs"), "--out", site, "--base-url", "/v2/") if err != nil || !strings.HasPrefix(out, "docs:build: wrote 6 pages to ") { t.Fatalf("docs:build --src --base-url = %q, %v", out, err) } if html, err := os.ReadFile(filepath.Join(site, "index.html")); err != nil || !strings.Contains(string(html), `href="/v2/assets/site.css"`) { t.Fatalf("--base-url not applied: %v", err) } } func TestDocsSyncOutput(t *testing.T) { root := scratchDocsRoot(t) out, err := runSummer(t, "docs:sync", "--root", root) if err != nil || out != "docs:sync: all snippets up to date\n" { t.Fatalf("docs:sync on the clean fixture = %q, %v", out, err) } start := filepath.Join(root, "docs", "guide", "start.md") raw, err := os.ReadFile(start) if err != nil { t.Fatal(err) } drifted := strings.Replace(string(raw), `demo.Hello("blog")`, `demo.Hello("stale")`, 1) if err := os.WriteFile(start, []byte(drifted), 0o644); err != nil { t.Fatal(err) } out, err = runSummer(t, "docs:sync", "--root", root) if err != nil || out != "docs:sync: updated 1 snippets in 1 files\n" { t.Fatalf("docs:sync with drift = %q, %v", out, err) } if got, _ := os.ReadFile(start); string(got) != string(raw) { t.Fatal("docs:sync did not restore the source text") } appendDoc(t, root, "docs/extras/faq.md", "\n```go src=modules/demo/demo.go#Nope\n```\n") out, err = runSummer(t, "docs:sync", "--root", root) if err == nil || err.Error() != "docs:sync failed" { t.Fatalf("docs:sync with a broken ref = %v", err) } if want := "docs/extras/faq.md:17: snippet: modules/demo/demo.go#Nope not found\ndocs:sync: 1 problems, nothing written\n"; out != want { t.Fatalf("docs:sync output =\n%s\nwant\n%s", out, want) } } func TestDocsServeRefusal(t *testing.T) { root := scratchDocsRoot(t) for _, addr := range []string{"0.0.0.0:8088", "[::]:8088", "192.0.2.10:8088"} { _, err := runSummer(t, "docs:serve", "--root", root, "--addr", addr) want := "docs:serve: refusing to listen on " + addr + ": not a loopback address. Pass --allow-remote to serve on the network." if err == nil || err.Error() != want { t.Errorf("docs:serve --addr %s = %v, want %q", addr, err, want) } } // --allow-remote passes the address check; the planted problem then // stops the first build before anything listens. appendDoc(t, root, "docs/extras/faq.md", "\n`summer no:such`\n") out, err := runSummer(t, "docs:serve", "--root", root, "--addr", "0.0.0.0:0", "--allow-remote") if err == nil || err.Error() != "docs:serve: build failed" || !strings.Contains(out, `"no:such"`) { t.Fatalf("docs:serve --allow-remote with a problem = %q, %v", out, err) } if strings.Contains(out, "Serving docs") { t.Fatal("docs:serve listened despite a failed build") } }