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:
516
cmd/summer/phase11_1_acceptance_test.go
Normal file
516
cmd/summer/phase11_1_acceptance_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@
|
|||||||
# orchestrates them and adds repository-level assertions: preconditions,
|
# orchestrates them and adds repository-level assertions: preconditions,
|
||||||
# the dependency delta, the built output and the forbidden-name sweep.
|
# the dependency delta, the built output and the forbidden-name sweep.
|
||||||
# --self-test plants one violation per rule in a scratch copy and requires
|
# --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
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="${PHASE11_1_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
ROOT="${PHASE11_1_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
@@ -31,6 +34,7 @@ usage:
|
|||||||
check-phase11.1.sh --forbidden
|
check-phase11.1.sh --forbidden
|
||||||
check-phase11.1.sh --claude
|
check-phase11.1.sh --claude
|
||||||
check-phase11.1.sh --self-test
|
check-phase11.1.sh --self-test
|
||||||
|
check-phase11.1.sh --named
|
||||||
check-phase11.1.sh --go
|
check-phase11.1.sh --go
|
||||||
check-phase11.1.sh --all
|
check-phase11.1.sh --all
|
||||||
EOF
|
EOF
|
||||||
@@ -302,6 +306,63 @@ run_self_test() {
|
|||||||
echo "phase11.1 self-test passed"
|
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
|
case "${1:-}" in
|
||||||
--preconditions) run_preconditions ;;
|
--preconditions) run_preconditions ;;
|
||||||
--deps) run_deps ;;
|
--deps) run_deps ;;
|
||||||
@@ -309,6 +370,7 @@ case "${1:-}" in
|
|||||||
--forbidden) run_forbidden ;;
|
--forbidden) run_forbidden ;;
|
||||||
--claude) run_claude ;;
|
--claude) run_claude ;;
|
||||||
--self-test) run_self_test ;;
|
--self-test) run_self_test ;;
|
||||||
|
--named) run_named ;;
|
||||||
--go) run_go ;;
|
--go) run_go ;;
|
||||||
--all)
|
--all)
|
||||||
run_preconditions
|
run_preconditions
|
||||||
@@ -317,6 +379,7 @@ case "${1:-}" in
|
|||||||
run_docs
|
run_docs
|
||||||
run_forbidden
|
run_forbidden
|
||||||
run_claude
|
run_claude
|
||||||
|
run_named
|
||||||
run_go
|
run_go
|
||||||
echo "phase11.1 all passed"
|
echo "phase11.1 all passed"
|
||||||
;;
|
;;
|
||||||
|
|||||||
Reference in New Issue
Block a user