- testdata/clean passes every check; 63 violation cases each overlay one fault and a want.txt naming the single problem it must produce - TestPlantedViolations also plants the forbidden name (split literals) and symlink escapes at run time; TestBuildOutputGuard covers --out - check-phase11.1.sh --self-test runs the corpus through a go test -json detector that refuses FAIL, SKIP, zero tests and no tests to run - the self-test scratch copy now carries examples/ and README.md, so its baseline sees the example command literals the real tree sees
422 lines
14 KiB
Go
422 lines
14 KiB
Go
package docsite
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// The planted-violation corpus. testdata/clean is a small repository root
|
|
// that passes every check. Each testdata/violations/<case> directory holds
|
|
// the files that differ from the clean fixture (copied over it, same
|
|
// relative paths), an optional remove.txt listing clean files to delete,
|
|
// and a want.txt naming the one problem the plant must produce:
|
|
//
|
|
// rule: frontmatter
|
|
// file: docs/guide/config.md
|
|
// message: unknown field "colour"
|
|
//
|
|
// The file line is optional; message is a substring of the problem
|
|
// message. A case passes only when Check reports exactly that one problem,
|
|
// so a checker that stops reporting a rule, or reports it under another
|
|
// rule, turns its case red.
|
|
|
|
const (
|
|
cleanFixture = "testdata/clean"
|
|
violationsFixture = "testdata/violations"
|
|
)
|
|
|
|
// violationCommands is the command set the corpus is checked with.
|
|
var violationCommands = &Commands{Tool: []string{"docs:build", "docs:sync"}, App: []string{"serve", "migrate"}}
|
|
|
|
// copyTree copies the regular files under src into dst.
|
|
func copyTree(t *testing.T, src, dst string) {
|
|
t.Helper()
|
|
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(src, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(dst, 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.Fatalf("copy %s: %v", src, err)
|
|
}
|
|
}
|
|
|
|
// cleanRoot copies the clean fixture into a fresh temp dir.
|
|
func cleanRoot(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
copyTree(t, cleanFixture, root)
|
|
return root
|
|
}
|
|
|
|
type wantProblem struct {
|
|
rule, file, message string
|
|
}
|
|
|
|
func (w wantProblem) matches(p Problem) bool {
|
|
return p.Rule == w.rule && (w.file == "" || p.File == w.file) && strings.Contains(p.Message, w.message)
|
|
}
|
|
|
|
func parseWant(t *testing.T, path string) wantProblem {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var w wantProblem
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
key, value, ok := strings.Cut(line, ": ")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "rule":
|
|
w.rule = value
|
|
case "file":
|
|
w.file = value
|
|
case "message":
|
|
w.message = value
|
|
default:
|
|
t.Fatalf("%s: unknown key %q", path, key)
|
|
}
|
|
}
|
|
if w.rule == "" || w.message == "" {
|
|
t.Fatalf("%s: want.txt needs a rule and a message", path)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// plantCase builds the scratch root of one violation case.
|
|
func plantCase(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
root := cleanRoot(t)
|
|
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(dir, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "want.txt" || rel == "remove.txt" {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(root, rel)
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(target, data, 0o644)
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if raw, err := os.ReadFile(filepath.Join(dir, "remove.txt")); err == nil {
|
|
for _, name := range strings.Fields(string(raw)) {
|
|
if err := os.Remove(filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
|
t.Fatalf("remove.txt: %v", err)
|
|
}
|
|
}
|
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatal(err)
|
|
}
|
|
return root
|
|
}
|
|
|
|
// assertOneProblem requires exactly one problem, matching want.
|
|
func assertOneProblem(t *testing.T, problems []Problem, want wantProblem) {
|
|
t.Helper()
|
|
if len(problems) != 1 || !want.matches(problems[0]) {
|
|
t.Fatalf("problems =\n%s\nwant exactly one %s problem in %q containing %q",
|
|
strings.Join(problemLines(problems), "\n"), want.rule, want.file, want.message)
|
|
}
|
|
}
|
|
|
|
func TestCleanFixture(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
problems, err := Check(Options{Root: root, Commands: violationCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(problems) > 0 {
|
|
t.Fatalf("clean fixture has problems:\n%s", strings.Join(problemLines(problems), "\n"))
|
|
}
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
res, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands})
|
|
if err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
|
}
|
|
// index, two guide pages, two extras pages and the demo API page.
|
|
if res.Pages != 6 {
|
|
t.Fatalf("pages = %d, want 6", res.Pages)
|
|
}
|
|
res2, problems, err := Sync(Options{Root: root})
|
|
if err != nil || len(problems) > 0 || res2 != (SyncResult{}) {
|
|
t.Fatalf("Sync on the clean fixture = %+v %q %v, want up to date", res2, problemLines(problems), err)
|
|
}
|
|
}
|
|
|
|
func TestPlantedViolations(t *testing.T) {
|
|
entries, err := os.ReadDir(violationsFixture)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var cases []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
cases = append(cases, e.Name())
|
|
}
|
|
}
|
|
if len(cases) < 30 {
|
|
t.Fatalf("%d violation cases, want at least 30", len(cases))
|
|
}
|
|
for _, name := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
dir := filepath.Join(violationsFixture, name)
|
|
want := parseWant(t, filepath.Join(dir, "want.txt"))
|
|
root := plantCase(t, dir)
|
|
problems, err := Check(Options{Root: root, Commands: violationCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertOneProblem(t, problems, want)
|
|
|
|
// Build must refuse the same tree and write nothing.
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
if _, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands}); err != nil || len(problems) == 0 {
|
|
t.Fatalf("Build accepted the planted tree: %v", err)
|
|
}
|
|
if _, err := os.Stat(out); !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatalf("Build wrote %s despite a problem", out)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Plants that cannot be committed: the forbidden word (assembled from
|
|
// split literals, so no committed file names a consuming application)
|
|
// and a symlink that leaves the root.
|
|
word := "fono" + "teka"
|
|
t.Run("forbidden-source", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
appendFile(t, root, "docs/extras/faq.md", "\nThe "+word+" application.\n")
|
|
problems, err := Check(Options{Root: root, Commands: violationCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertOneProblem(t, problems, wantProblem{rule: "forbidden", file: "docs/extras/faq.md", message: forbiddenMessage})
|
|
if strings.Contains(strings.ToLower(problems[0].String()), word) {
|
|
t.Fatalf("problem line repeats the match: %s", problems[0])
|
|
}
|
|
})
|
|
t.Run("forbidden-accented", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
appendFile(t, root, "docs/extras/faq.md", "\nSee "+"P"+"ł"+"ý"+"tarium.\n")
|
|
problems, err := Check(Options{Root: root, Commands: violationCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertOneProblem(t, problems, wantProblem{rule: "forbidden", file: "docs/extras/faq.md", message: forbiddenMessage})
|
|
})
|
|
t.Run("forbidden-output", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
site := filepath.Join(root, "docs/site.yaml")
|
|
raw, err := os.ReadFile(site)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw = []byte(strings.Replace(string(raw), "the clean docs checker fixture", "the "+word+" fixture", 1))
|
|
if err := os.WriteFile(site, raw, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
problems, err := Check(Options{Root: root, Commands: violationCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// site.yaml is not a page, so only the rendered outputs carry it:
|
|
// every page's text outputs, llms.txt and llms-full.txt.
|
|
if len(problems) == 0 || !slices.ContainsFunc(problems, func(p Problem) bool { return p.File == "llms.txt" }) {
|
|
t.Fatalf("output scan missed llms.txt: %q", problemLines(problems))
|
|
}
|
|
for _, p := range problems {
|
|
if p.Rule != "forbidden" || p.Message != forbiddenMessage {
|
|
t.Fatalf("unexpected problem %s", p)
|
|
}
|
|
}
|
|
})
|
|
t.Run("snippet-symlink-escape", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
outside := filepath.Join(t.TempDir(), "secret.txt")
|
|
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(root, "config", "escape.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appendFile(t, root, "docs/extras/faq.md", "\n```text src=config/escape.txt\nsecret\n```\n")
|
|
problems, err := Check(Options{Root: root, Commands: violationCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertOneProblem(t, problems, wantProblem{rule: "snippet", file: "docs/extras/faq.md", message: "path resolves outside the repository root"})
|
|
})
|
|
t.Run("snippet-symlink-dotfile", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
if err := os.WriteFile(filepath.Join(root, ".secret"), []byte("secret\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(filepath.Join(root, ".secret"), filepath.Join(root, "config", "alias.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appendFile(t, root, "docs/extras/faq.md", "\n```text src=config/alias.txt\nsecret\n```\n")
|
|
problems, err := Check(Options{Root: root, Commands: violationCommands})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertOneProblem(t, problems, wantProblem{rule: "snippet", file: "docs/extras/faq.md", message: "path resolves to a dotfile or .env file"})
|
|
})
|
|
}
|
|
|
|
func appendFile(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)
|
|
}
|
|
}
|
|
|
|
// TestBuildOutputGuard asserts that Build refuses an output directory that
|
|
// would overwrite the sources or an unrelated directory, and leaves a
|
|
// sentinel file in it untouched.
|
|
func TestBuildOutputGuard(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
// opts returns the Src and Out for a fixture root and the directory
|
|
// the sentinel goes into.
|
|
opts func(root string) (src, out, sentinelDir string)
|
|
want string
|
|
}{
|
|
{"out-equals-root", func(root string) (string, string, string) {
|
|
return "", root, root
|
|
}, "--out must not be inside --src or equal to the repository root"},
|
|
{"out-inside-src", func(root string) (string, string, string) {
|
|
return "", filepath.Join(root, "docs", "site"), filepath.Join(root, "docs")
|
|
}, "--out must not be inside --src or equal to the repository root"},
|
|
{"out-equals-src", func(root string) (string, string, string) {
|
|
return "", filepath.Join(root, "docs"), filepath.Join(root, "docs")
|
|
}, "--out must not be inside --src or equal to the repository root"},
|
|
{"out-contains-src", func(root string) (string, string, string) {
|
|
return filepath.Join(root, "content", "docs"), filepath.Join(root, "content"), filepath.Join(root, "content")
|
|
}, "--out must not be inside --src or equal to the repository root"},
|
|
{"out-contains-root", func(root string) (string, string, string) {
|
|
return "", filepath.Dir(root), filepath.Dir(root)
|
|
}, "--out must not be inside --src or equal to the repository root"},
|
|
{"out-unmarked", func(root string) (string, string, string) {
|
|
out := filepath.Join(t.TempDir(), "unrelated")
|
|
return "", out, out
|
|
}, "has no .summer-docs marker"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
src, out, sentinelDir := tc.opts(root)
|
|
if src != "" {
|
|
if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Rename(filepath.Join(root, "docs"), src); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(sentinelDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sentinel := filepath.Join(sentinelDir, "sentinel.txt")
|
|
if err := os.WriteFile(sentinel, []byte("keep me\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, problems, err := Build(Options{Root: root, Src: src, Out: out, Commands: violationCommands})
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("Build = %v (problems %q), want an error containing %q", err, problemLines(problems), tc.want)
|
|
}
|
|
if got, err := os.ReadFile(sentinel); err != nil || string(got) != "keep me\n" {
|
|
t.Fatalf("sentinel changed: %q %v", got, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(out, MarkerFile)); !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatalf("Build wrote the marker into a refused --out")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("out-symlink-into-src", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
link := filepath.Join(t.TempDir(), "site")
|
|
if err := os.Symlink(filepath.Join(root, "docs"), link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, err := Build(Options{Root: root, Out: link, Commands: violationCommands})
|
|
if !errors.Is(err, errOutInside) {
|
|
t.Fatalf("Build through a symlink into docs/ = %v, want errOutInside", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "docs", "index.md")); err != nil {
|
|
t.Fatalf("docs/index.md removed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("out-marked-is-cleaned", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
if err := os.MkdirAll(filepath.Join(out, "stale"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{MarkerFile, "stale/old.html"} {
|
|
if err := os.WriteFile(filepath.Join(out, name), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build into a marked directory: %v %q", err, problemLines(problems))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(out, "stale")); !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatal("a stale file survived the clean")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(out, "index.html")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
t.Run("out-empty-dir", func(t *testing.T) {
|
|
root := cleanRoot(t)
|
|
out := t.TempDir()
|
|
if _, problems, err := Build(Options{Root: root, Out: out, Commands: violationCommands}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build into an empty directory: %v %q", err, problemLines(problems))
|
|
}
|
|
if _, err := os.Stat(filepath.Join(out, MarkerFile)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
}
|