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/ 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) } }) }