test(11.1-06): acceptance subtests per success criterion and the final docs gate

- TestPhase11_1Acceptance asserts SC1 to SC5 on the real tree: sections
  and module pages, theme markers and no Node exec, AI outputs in sync,
  every go fence a src= copy run by go test, concept map and walkthrough
- check-phase11.1.sh --named runs every named test of the phase by exact
  name (modules' TestDocs* and output Examples derived from source) and
  refuses failures, skips, missing or renamed tests and no tests to run
- --all runs preconditions, deps, self-test, docs, forbidden, claude,
  named and the full go vet and go test
This commit is contained in:
Jakub Zych
2026-10-01 00:10:12 +02:00
parent 28afd4d197
commit c1c9a9f853
2 changed files with 580 additions and 1 deletions

View File

@@ -0,0 +1,516 @@
package main
import (
"bytes"
"go/ast"
"go/doc"
"go/parser"
"go/token"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/summercms/internal/docsite"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// TestPhase11_1Acceptance asserts the Phase 11.1 success criteria on the
// real tree, one subtest per ROADMAP criterion.
func TestPhase11_1Acceptance(t *testing.T) {
opts := docsite.Options{Root: repoRoot, Commands: docsCommands()}
pages, loadProblems, err := docsite.Pages(opts)
if err != nil {
t.Fatal(err)
}
problems, err := docsite.Check(opts)
if err != nil {
t.Fatal(err)
}
out, _ := buildRealTree(t)
// SC1: docs/ holds Markdown pages with frontmatter in the
// Winter-mirroring sections, and every framework module is reachable
// from the sidebar.
t.Run("SC1", func(t *testing.T) {
site, err := docsite.LoadSite(filepath.Join(repoRoot, "docs", "site.yaml"))
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range site.Sections {
names = append(names, s.Name)
}
if !slices.Equal(names, sectionOrder) {
t.Errorf("site.yaml sections = %v, want %v", names, sectionOrder)
}
for _, p := range append(slices.Clone(loadProblems), problems...) {
switch p.Rule {
case "frontmatter", "section", "site", "page", "readme":
t.Errorf("load problem: %s", p)
}
}
guides := 0
for _, p := range pages {
if p.Module != "" {
continue
}
guides++
if p.Title == "" || p.Description == "" || !strings.HasPrefix(p.Source, "docs/") || !strings.HasSuffix(p.Source, ".md") {
t.Errorf("page %s has incomplete frontmatter: %+v", p.URL, p)
}
if p.Section != "index" && (!slices.Contains(sectionOrder, p.Section) || p.Section == "api") {
t.Errorf("page %s is in section %q", p.URL, p.Section)
}
}
for _, sec := range sectionOrder[:len(sectionOrder)-1] {
if !slices.ContainsFunc(pages, func(p docsite.Page) bool { return p.Section == sec }) {
t.Errorf("section %s has no pages", sec)
}
}
if guides < len(requiredPages) {
t.Errorf("%d guide pages, want at least %d", guides, len(requiredPages))
}
sidebar := sidebarOf(t, out, "setup/installation.html")
for _, m := range frameworkModules(t) {
if !slices.ContainsFunc(pages, func(p docsite.Page) bool { return p.URL == "api/"+m && p.Module == m }) {
t.Errorf("module %s has no api/%s page", m, m)
}
if !strings.Contains(sidebar, `href="/api/`+m+`.html"`) {
t.Errorf("the sidebar does not link api/%s", m)
}
}
})
// SC2: summer builds a self-contained static site with the UI-SPEC
// theme parts, search and dark mode, with no Node toolchain.
t.Run("SC2", func(t *testing.T) {
guide := readFile(t, filepath.Join(out, "setup", "installation.html"))
for _, marker := range []string{
`<body class="docs">`,
`<header class="site-header">`,
`<nav class="sidebar" aria-label="Documentation">`,
`<aside class="toc" aria-label="On this page">`,
`<details class="toc-inline">`,
`<div class="page-actions">`,
`<nav class="pager" aria-label="Previous and next page">`,
`<footer class="site-footer">`,
`<dialog class="search" id="search">`,
`<button class="theme-toggle"`,
`<figure class="code">`,
`<a class="heading-anchor"`,
`<svg class="icon icon-`,
`<link rel="stylesheet" href="/assets/site.css">`,
`<script src="/assets/theme-init.js"></script>`,
`<script src="/assets/search.js"`,
} {
if !strings.Contains(guide, marker) {
t.Errorf("setup/installation.html is missing the theme marker %s", marker)
}
}
// Callouts of every type appear on the guide pages.
guides := guidePagesHTML(t, out, pages)
for _, kind := range []string{"note", "tip", "warning"} {
marker := `<aside class="callout callout-` + kind + `" role="note">`
if !strings.Contains(guides, marker) {
t.Errorf("no guide page renders %s", marker)
}
}
if nf := readFile(t, filepath.Join(out, "404.html")); !strings.Contains(nf, `<body class="docs docs-404">`) {
t.Error("404.html has no not-found body marker")
}
for _, name := range []string{"site.css", "site.js", "search.js", "theme-init.js"} {
assertFile(t, filepath.Join(out, "assets", name))
}
fonts, err := filepath.Glob(filepath.Join(out, "assets", "fonts", "*.woff2"))
if err != nil || len(fonts) != 8 {
t.Errorf("fonts = %v, want 8 woff2 files", fonts)
}
for _, name := range []string{"search-index.json", "assets/LICENSE-lucide.txt", "assets/fonts/LICENSE-dm-sans.txt", "assets/fonts/LICENSE-dm-mono.txt"} {
assertFile(t, filepath.Join(out, filepath.FromSlash(name)))
}
if strings.Contains(guide, "//fonts.") || strings.Contains(guide, "cdn.") || strings.Contains(guide, "unpkg") {
t.Error("the guide page loads an asset from a third-party origin")
}
// No Node toolchain: nothing in the generator or the docs commands
// executes node, npm or npx.
files, err := filepath.Glob(filepath.Join(repoRoot, "internal", "docsite", "*.go"))
if err != nil {
t.Fatal(err)
}
files = append(files, "docs.go")
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
for _, prog := range execPrograms(t, f) {
if prog == "" || slices.Contains([]string{"node", "npm", "npx", "pnpm", "yarn"}, filepath.Base(prog)) {
t.Errorf("%s executes %q (the docs build must not need a Node toolchain)", f, prog)
}
}
}
if !slices.ContainsFunc(toolCommands(), func(c bonfire.Command) bool { return c.Name == "docs:serve" }) {
t.Error("docs:serve is not registered")
}
})
// SC3: llms.txt, llms-full.txt and a .md per page stay in sync with
// the page tree.
t.Run("SC3", func(t *testing.T) {
var urls []string
for _, p := range pages {
urls = append(urls, p.URL)
}
html, md := outputPages(t, out)
sorted := slices.Sorted(slices.Values(urls))
if !slices.Equal(html, sorted) || !slices.Equal(md, sorted) {
t.Errorf("html = %v\nmd = %v\nwant %v", html, md, sorted)
}
var linked []string
for _, line := range strings.Split(readFile(t, filepath.Join(out, "llms.txt")), "\n") {
if rest, ok := strings.CutPrefix(line, "- ["); ok {
_, target, _ := strings.Cut(rest, "](/")
target, _, _ = strings.Cut(target, ".md)")
linked = append(linked, target)
}
}
if !slices.Equal(linked, urls) {
t.Errorf("llms.txt links = %v, want %v", linked, urls)
}
var sources []string
for _, line := range strings.Split(readFile(t, filepath.Join(out, "llms-full.txt")), "\n") {
if rest, ok := strings.CutPrefix(line, "Source: /"); ok {
sources = append(sources, strings.TrimSuffix(rest, ".html"))
}
}
if !slices.Equal(sources, urls) {
t.Errorf("llms-full.txt sources = %v, want %v", sources, urls)
}
for _, u := range []string{"index", "setup/installation"} {
md := readFile(t, filepath.Join(out, filepath.FromSlash(u)+".md"))
if !strings.HasPrefix(md, "# ") || strings.HasPrefix(md, "---") || strings.Contains(md, "src=") {
t.Errorf("%s.md is not a clean Markdown page", u)
}
}
})
// SC4: every Go example in a docs/ page is compiled and run by go test,
// and the checkers report nothing on the real tree.
t.Run("SC4", func(t *testing.T) {
for _, p := range problems {
t.Errorf("docs problem: %s", p)
}
refs := docsGoFences(t)
if len(refs) < 50 {
t.Fatalf("%d go fences under docs/, expected the full content set", len(refs))
}
dirs := map[string]bool{}
examples := 0
for _, f := range refs {
if f.src == "" {
t.Errorf("%s:%d: go fence without src=", f.file, f.line)
continue
}
path, frag, _ := strings.Cut(f.src, "#")
if !strings.HasSuffix(path, ".go") {
t.Errorf("%s:%d: go fence names a non-Go source %s", f.file, f.line, f.src)
continue
}
dirs[filepath.Dir(path)] = true
if strings.HasPrefix(frag, "Example") {
examples++
if !exampleWithOutput(t, filepath.Join(repoRoot, filepath.FromSlash(path)), frag) {
t.Errorf("%s:%d: %s is not an Example with an // Output: comment", f.file, f.line, f.src)
}
}
}
if examples < 20 {
t.Errorf("%d Example fences, expected the full content set", examples)
}
// Every referenced directory is a package of the root module, so
// go test ./... compiles and runs it.
listed := goList(t, "./...")
for dir := range dirs {
if !listed[filepath.ToSlash(dir)] {
t.Errorf("%s is not a package of the root module", dir)
}
}
})
// SC5: the concept map and the acme/blog walkthrough exist, and the
// walkthrough's code is verified under SC4.
t.Run("SC5", func(t *testing.T) {
for _, u := range []string{"setup/coming-from-wintercms", "setup/porting-a-plugin"} {
if !slices.ContainsFunc(pages, func(p docsite.Page) bool { return p.URL == u }) {
t.Errorf("page %s is missing", u)
}
}
const walkthrough = "docs/setup/porting-a-plugin.md"
srcs := 0
for _, f := range pageFences(t, walkthrough) {
lang, _, _ := strings.Cut(f.info, " ")
switch {
case lang == "go" && !strings.HasPrefix(f.src, "docs/examples/blog/"):
t.Errorf("%s:%d: go fence is not a src= copy of docs/examples/blog (%q)", walkthrough, f.line, f.info)
case f.src == "" && strings.Contains(f.body, "docs/examples/blog"):
t.Errorf("%s:%d: fence shows docs/examples/blog code without src=", walkthrough, f.line)
case strings.HasPrefix(f.src, "docs/examples/blog/"):
srcs++
}
}
if srcs < 15 {
t.Errorf("%d src= fences into docs/examples/blog, expected the full walkthrough", srcs)
}
concept := readFile(t, filepath.Join(repoRoot, "docs", "setup", "coming-from-wintercms.md"))
if !strings.Contains(concept, "porting-a-plugin.md") {
t.Error("the concept map does not link the walkthrough")
}
listed := goList(t, "./docs/examples/...")
for _, pkg := range []string{"docs/examples/blog", "docs/examples/blog/models", "docs/examples/blog/updates", "docs/examples/blog/controllers", "docs/examples/blog/console"} {
if !listed[pkg] {
t.Errorf("go list ./docs/examples/... does not include %s", pkg)
}
}
if _, err := os.Stat(filepath.Join(repoRoot, "docs", "examples", "blog", "go.mod")); err == nil {
t.Error("docs/examples/blog is a nested module; go test ./... would not run it")
}
})
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func assertFile(t *testing.T, path string) {
t.Helper()
if st, err := os.Stat(path); err != nil || st.Size() == 0 {
t.Errorf("missing or empty %s: %v", path, err)
}
}
// sidebarOf returns the sidebar nav of a built page.
func sidebarOf(t *testing.T, out, name string) string {
t.Helper()
html := readFile(t, filepath.Join(out, filepath.FromSlash(name)))
start := strings.Index(html, `<nav class="sidebar" aria-label="Documentation">`)
if start < 0 {
t.Fatalf("%s has no sidebar", name)
}
end := strings.Index(html[start:], "</nav>")
if end < 0 {
t.Fatalf("%s has an unterminated sidebar", name)
}
return html[start : start+end]
}
// guidePagesHTML concatenates the built HTML of every guide page.
func guidePagesHTML(t *testing.T, out string, pages []docsite.Page) string {
t.Helper()
var b strings.Builder
for _, p := range pages {
if p.Module == "" {
b.WriteString(readFile(t, filepath.Join(out, filepath.FromSlash(p.URL)+".html")))
}
}
return b.String()
}
// outputPages lists the built .html and .md pages (404.html and assets
// excluded), sorted.
func outputPages(t *testing.T, out string) (html, md []string) {
t.Helper()
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/") || rel == "404.html" {
return nil
}
switch filepath.Ext(rel) {
case ".html":
html = append(html, strings.TrimSuffix(rel, ".html"))
case ".md":
md = append(md, strings.TrimSuffix(rel, ".md"))
}
return nil
})
if err != nil {
t.Fatal(err)
}
slices.Sort(html)
slices.Sort(md)
return html, md
}
// execPrograms returns the program argument of every exec.Command and
// exec.CommandContext call in a Go file: the literal value, or "" when it
// is not a string literal.
func execPrograms(t *testing.T, file string) []string {
t.Helper()
f, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
if err != nil {
t.Fatal(err)
}
execName := ""
for _, imp := range f.Imports {
if p, _ := strconv.Unquote(imp.Path.Value); p == "os/exec" {
execName = "exec"
if imp.Name != nil {
execName = imp.Name.Name
}
}
}
if execName == "" {
return nil
}
var progs []string
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
if x, ok := sel.X.(*ast.Ident); !ok || x.Name != execName {
return true
}
arg := -1
switch sel.Sel.Name {
case "Command":
arg = 0
case "CommandContext":
arg = 1
}
if arg < 0 || len(call.Args) <= arg {
return true
}
prog := ""
if lit, ok := call.Args[arg].(*ast.BasicLit); ok && lit.Kind == token.STRING {
prog, _ = strconv.Unquote(lit.Value)
}
progs = append(progs, prog)
return true
})
return progs
}
// docFence is one fenced code block of a docs page, found by an
// independent scanner (not docsite's).
type docFence struct {
file, info, src, body string
line int
}
func pageFences(t *testing.T, rel string) []docFence {
t.Helper()
var fences []docFence
lines := strings.Split(readFile(t, filepath.Join(repoRoot, filepath.FromSlash(rel))), "\n")
for i := 0; i < len(lines); i++ {
trimmed := strings.TrimLeft(lines[i], " ")
if !strings.HasPrefix(trimmed, "```") && !strings.HasPrefix(trimmed, "~~~") {
continue
}
marker := trimmed[:3]
f := docFence{file: rel, info: strings.TrimSpace(strings.TrimLeft(trimmed, marker[:1])), line: i + 1}
for _, field := range strings.Fields(f.info) {
if v, ok := strings.CutPrefix(field, "src="); ok {
f.src = v
}
}
var body []string
for i++; i < len(lines) && !strings.HasPrefix(strings.TrimLeft(lines[i], " "), marker); i++ {
body = append(body, lines[i])
}
f.body = strings.Join(body, "\n")
fences = append(fences, f)
}
return fences
}
// docsGoFences lists every go fence of every page under docs/ (the
// docs/examples tree is code, not pages).
func docsGoFences(t *testing.T) []docFence {
t.Helper()
var out []docFence
err := filepath.WalkDir(filepath.Join(repoRoot, "docs"), func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == "examples" {
return filepath.SkipDir
}
if d.IsDir() || !strings.HasSuffix(p, ".md") {
return nil
}
rel, _ := filepath.Rel(repoRoot, p)
for _, f := range pageFences(t, filepath.ToSlash(rel)) {
if lang, _, _ := strings.Cut(f.info, " "); lang == "go" {
out = append(out, f)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
// exampleWithOutput reports whether file declares the Example function name
// with an // Output: comment, so go test runs it.
func exampleWithOutput(t *testing.T, file, name string) bool {
t.Helper()
f, err := parser.ParseFile(token.NewFileSet(), file, nil, parser.ParseComments)
if err != nil {
t.Errorf("parse %s: %v", file, err)
return false
}
for _, ex := range doc.Examples(f) {
if "Example"+ex.Name == name {
return ex.Output != "" || ex.EmptyOutput
}
}
return false
}
// goList returns the repository-relative directories of the packages a
// go list pattern matches in the root module (workspace off).
func goList(t *testing.T, pattern string) map[string]bool {
t.Helper()
cmd := exec.Command("go", "list", "-f", "{{.Dir}}", pattern)
cmd.Dir = repoRoot
// Without the workspace, ./... matches the root module's packages only,
// which is what the root go test ./... runs.
cmd.Env = append(os.Environ(), "GOWORK=off")
var stderr bytes.Buffer
cmd.Stderr = &stderr
raw, err := cmd.Output()
if err != nil {
t.Fatalf("go list %s: %v\n%s", pattern, err, stderr.String())
}
root, err := filepath.Abs(repoRoot)
if err != nil {
t.Fatal(err)
}
dirs := map[string]bool{}
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if rel, err := filepath.Rel(root, line); err == nil && !strings.HasPrefix(rel, "..") {
dirs[filepath.ToSlash(rel)] = true
}
}
return dirs
}

View File

@@ -8,7 +8,10 @@
# orchestrates them and adds repository-level assertions: preconditions,
# the dependency delta, the built output and the forbidden-name sweep.
# --self-test plants one violation per rule in a scratch copy and requires
# docs:build --check to refuse it for that rule.
# docs:build --check to refuse it for that rule, then runs the Go
# planted-violation corpus. --named runs every named test of the phase by
# exact name and requires each to PASS: a failure, a skip, a missing or
# renamed test and "no tests to run" all refuse.
set -euo pipefail
ROOT="${PHASE11_1_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
@@ -31,6 +34,7 @@ usage:
check-phase11.1.sh --forbidden
check-phase11.1.sh --claude
check-phase11.1.sh --self-test
check-phase11.1.sh --named
check-phase11.1.sh --go
check-phase11.1.sh --all
EOF
@@ -302,6 +306,63 @@ run_self_test() {
echo "phase11.1 self-test passed"
}
# docs_example_tests prints the TestDocs* functions and the Example*
# functions with an output comment (the ones go test runs) declared in the
# _test.go files of one package directory.
docs_example_tests() {
python3 - "$ROOT/$1" <<'PY'
import glob, os, re, sys
names = []
for path in sorted(glob.glob(os.path.join(sys.argv[1], "*_test.go"))):
src = open(path, encoding="utf-8").read()
for m in re.finditer(r"^func (TestDocs\w*|Example\w*)\(", src, re.M):
name = m.group(1)
if name.startswith("Example"):
end = re.search(r"^}", src[m.end():], re.M)
body = src[m.end():m.end() + (end.start() if end else len(src))]
if not re.search(r"//\s*(Unordered output|Output):", body):
continue
names.append(name)
print(" ".join(names))
PY
}
# Named tests of the phase, by package. The docs examples in modules are
# derived from the sources (every TestDocs* and every Example with an
# output comment); the generator, CLI and walkthrough tests are listed.
DOCSITE_TESTS=(TestPlantedViolations TestCleanFixture TestBuildOutputGuard TestSlugIDs TestSlugIDsEdgeCases
TestReadmeIngestion TestSnippetForms TestSnippetConfinement TestSyncRewritesDrift TestSnippetGenericsAndGroups
TestSyncPreservesAndReports TestIdentifierChecker TestIdentifierIndexDuplicateName TestIdentifierIndexForms
TestIdentifierGoDocFallback TestLinkChecker TestCommandChecker TestCommandTokenForms TestForbiddenChecker
TestFencePolicy TestFrontmatterEncodingProblems TestParseSite TestLLMSTxtShape TestLLMSFullBlocks
TestMarkdownSiblings TestSearchIndexSchema TestBaseURLPrefixing TestTOCThreshold TestLinkRewriting
TestCalloutRendering TestHighlightGo TestHighlightYAML TestHighlightShell TestHighlightFallback
TestBuildSiteMarkers TestThemeAssetsAndPager TestServeHandler TestServeHandlerBranches TestServeAddrPolicy
TestServeRefusesNonLoopback TestServeRebuildKeepsLastGoodBuild TestServeWatchRebuilds)
SUMMER_TESTS=(TestPhase11_1Acceptance TestDocsTree TestDocsBuildRealTree TestEveryModuleInSidebar
TestDocsAIOutputsInSync TestDocsRequiredPages TestDocsCommandNames TestDocsCommandsMirrorGeneratedMain
TestDocsBuildCheckOutput TestDocsSyncOutput TestDocsServeRefusal TestToolCommandNames)
run_named() {
local dir names
go_json_named ./internal/docsite "${DOCSITE_TESTS[@]}" || refuse "named: internal/docsite"
go_json_named ./cmd/summer "${SUMMER_TESTS[@]}" || refuse "named: cmd/summer"
go_json_named ./docs/examples/blog TestPluginActivates TestRoutesRegistered TestScaffoldLayout \
TestMigrateUpAndRollback TestPostsRouteAgainstDatabase TestPublishCommandAgainstDatabase \
TestPublishCommandOpensDatabase || refuse "named: docs/examples/blog"
go_json_named ./docs/examples/blog/models TestPostTableAndFill || refuse "named: docs/examples/blog/models"
go_json_named ./docs/examples/blog/updates TestMigrationIDs || refuse "named: docs/examples/blog/updates"
go_json_named ./docs/examples/blog/controllers TestPostsControllerDeclaration || refuse "named: docs/examples/blog/controllers"
go_json_named ./docs/examples/blog/console TestPublishCommandShape || refuse "named: docs/examples/blog/console"
while IFS= read -r dir; do
names="$(docs_example_tests "$dir")"
[ -n "$names" ] || refuse "named: $dir has an example_test.go but no Example with output or TestDocs test"
# shellcheck disable=SC2086 # names is a space-separated list of identifiers
go_json_named "./$dir" $names || refuse "named: $dir"
done < <(cd "$ROOT" && find modules -name example_test.go -not -path '*/testdata/*' -printf '%h\n' | sort -u)
echo "phase11.1 named passed"
}
case "${1:-}" in
--preconditions) run_preconditions ;;
--deps) run_deps ;;
@@ -309,6 +370,7 @@ case "${1:-}" in
--forbidden) run_forbidden ;;
--claude) run_claude ;;
--self-test) run_self_test ;;
--named) run_named ;;
--go) run_go ;;
--all)
run_preconditions
@@ -317,6 +379,7 @@ case "${1:-}" in
run_docs
run_forbidden
run_claude
run_named
run_go
echo "phase11.1 all passed"
;;