go-doc/backend/docgen/docgen_test.go

622 lines
18 KiB
Go
Raw Normal View History

2026-10-07 15:03:50 +02:00
package docgen
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fixture writes a small but representative project tree: all three route
// registration styles, a module file, a Makefile, a Dockerfile, a compose file,
// an .env, a README and one unrelated Markdown document.
func fixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
files := map[string]string{
"README.md": "# Demo Service\n\nDemo is a service that answers questions about things. " +
"It runs behind Go Loose and exposes a JSON API.\n\n" +
"## Install\n\nNot part of the summary.\n",
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire (\n" +
"\tgithub.com/go-chi/chi/v5 v5.2.3\n\tgithub.com/spf13/cobra v1.9.1\n" +
"\tgolang.org/x/sync v0.14.0 // indirect\n)\n",
"Makefile": "build:\n\tgo build ./...\n\n.PHONY: build\n\ntest:\n\tgo test ./...\n",
"Dockerfile": "FROM golang:1.27-alpine AS builder\nRUN go build ./...\n\n" +
"FROM alpine:3.20\nCOPY --from=builder /app/demo /demo\nEXPOSE 8080\n",
"docker-compose.yml": "services:\n api:\n image: demo:latest\n ports:\n" +
" - \"8080:8080\"\n db:\n image: postgres:17\n environment:\n" +
" POSTGRES_PASSWORD: secret\n",
".env.example": "# comment\nDEMO_HTTP_PORT=8080\nDEMO_API_KEY=\n",
"internal/server/routes.go": "package server\n\n" +
"import (\n\t\"net/http\"\n\t\"os\"\n)\n\n" +
"func Routes(mux *http.ServeMux, r chi.Router) {\n" +
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
"\tmux.HandleFunc(\"/plain\", plainHandler)\n" +
"\tregister(r, http.MethodPost, \"/api/items\", createItem)\n" +
"\tr.Get(\"/api/items/{id}\", getItem)\n" +
"\t_ = os.Getenv(\"DEMO_RUNTIME_SECRET\")\n}\n",
"internal/server/routes_test.go": "package server\n\n" +
"func TestRoutes(t *testing.T) {\n" +
"\t_ = httptest.NewRequest(http.MethodGet, \"/api/jobs\", nil)\n}\n",
"cmd/demo/main.go": "package main\n\nimport \"flag\"\n\nfunc main() {\n" +
"\tport := flag.String(\"port\", \"8080\", \"listen port\")\n" +
"\tverbose := flag.Bool(\"verbose\", false, \"chatty output\")\n" +
"\t_ = port\n\t_ = verbose\n}\n",
"docs/OPERATIONS.md": "# Operations\n\nHow to run the thing.\n",
}
for name, content := range files {
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// A directory that must never be walked.
if err := os.MkdirAll(filepath.Join(root, "node_modules", "left-pad"), 0o755); err != nil {
t.Fatalf("mkdir node_modules: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "node_modules", "index.js"), []byte("x\n"), 0o644); err != nil {
t.Fatalf("write node_modules file: %v", err)
}
return root
}
func analyzeFixture(t *testing.T) *Project {
t.Helper()
p, err := Analyze("demo", fixture(t))
if err != nil {
t.Fatalf("Analyze: %v", err)
}
return p
}
func TestAnalyzeExtractsModuleMetadata(t *testing.T) {
p := analyzeFixture(t)
if len(p.Modules) != 1 {
t.Fatalf("want 1 module, got %d: %+v", len(p.Modules), p.Modules)
}
module := p.Modules[0]
if module.Path != "github.com/danyel/demo" {
t.Errorf("module path = %q", module.Path)
}
if module.GoVersion != "1.27.1" {
t.Errorf("go version = %q", module.GoVersion)
}
if len(module.Direct) != 2 {
t.Errorf("want 2 direct deps, got %d: %+v", len(module.Direct), module.Direct)
}
if module.Direct[0].Path != "github.com/go-chi/chi/v5" || module.Direct[0].Version != "v5.2.3" {
t.Errorf("unexpected first dep: %+v", module.Direct[0])
}
if module.Indirect != 1 {
t.Errorf("want 1 indirect dep, got %d", module.Indirect)
}
}
func TestAnalyzeExtractsAllRouteStyles(t *testing.T) {
p := analyzeFixture(t)
want := map[string]string{
"GET /healthz": "health",
"/plain": "plainHandler",
"POST /api/items": "createItem",
"GET /api/items/{id}": "getItem",
}
for key, handler := range want {
var found *Route
for i := range p.Routes {
if p.Routes[i].String() == key {
found = &p.Routes[i]
}
}
if found == nil {
t.Errorf("route %q not found; got %v", key, routeStrings(p.Routes))
continue
}
if found.Handler != handler {
t.Errorf("route %q handler = %q, want %q", key, found.Handler, handler)
}
}
if len(p.Routes) != len(want) {
t.Errorf("want %d routes, got %d: %v", len(want), len(p.Routes), routeStrings(p.Routes))
}
}
func TestAnalyzeIgnoresRoutesInTestFiles(t *testing.T) {
p := analyzeFixture(t)
for _, route := range p.Routes {
if route.Path == "/api/jobs" {
t.Fatalf("route from a _test.go file leaked into the analysis: %v", route)
}
}
}
func TestAnalyzeExtractsFlagsAndEnvironment(t *testing.T) {
p := analyzeFixture(t)
flags := map[string]Flag{}
for _, flag := range p.Flags {
flags[flag.Name] = flag
}
if flags["port"].Default != "8080" || flags["port"].Usage != "listen port" {
t.Errorf("port flag = %+v", flags["port"])
}
if flags["verbose"].Type != "bool" {
t.Errorf("verbose flag = %+v", flags["verbose"])
}
env := map[string]EnvVar{}
for _, v := range p.EnvVars {
env[v.Name] = v
}
if env["DEMO_RUNTIME_SECRET"].FromEnv {
t.Error("os.Getenv should not be reported as coming from a .env file")
}
if !env["DEMO_HTTP_PORT"].FromEnv || env["DEMO_HTTP_PORT"].Example != "8080" {
t.Errorf("DEMO_HTTP_PORT = %+v", env["DEMO_HTTP_PORT"])
}
}
func TestAnalyzeExtractsOperationalFacts(t *testing.T) {
p := analyzeFixture(t)
if got := p.MakeTargets; len(got) != 2 || got[0] != "build" || got[1] != "test" {
t.Errorf("make targets = %v", got)
}
if p.Docker == nil {
t.Fatal("no Dockerfile analysed")
}
if len(p.Docker.Bases) != 2 || p.Docker.Bases[0] != "golang:1.27-alpine" {
t.Errorf("docker bases = %v", p.Docker.Bases)
}
if len(p.Docker.Exposed) != 1 || p.Docker.Exposed[0] != "8080" {
t.Errorf("docker exposed = %v", p.Docker.Exposed)
}
if len(p.Compose) != 2 {
t.Fatalf("compose services = %+v", p.Compose)
}
if p.Compose[0].Name != "api" || p.Compose[0].Image != "demo:latest" {
t.Errorf("compose[0] = %+v", p.Compose[0])
}
if len(p.Ports) == 0 {
t.Error("expected the compose file port to be picked up")
}
if p.Readme != "README.md" {
t.Errorf("readme = %q", p.Readme)
}
if len(p.Docs) != 2 {
t.Errorf("docs = %+v", p.Docs)
}
}
func TestAnalyzeIgnoresVendoredTrees(t *testing.T) {
p := analyzeFixture(t)
for _, lang := range p.Languages {
if lang.Name == "JavaScript" {
t.Errorf("node_modules leaked into the language table: %+v", p.Languages)
}
}
for _, dir := range p.Dirs {
if dir.Name == "node_modules" {
t.Errorf("node_modules leaked into the directory map: %+v", dir)
}
}
}
func TestSummaryComesFromReadmeProse(t *testing.T) {
p := analyzeFixture(t)
if !strings.HasPrefix(p.Summary, "Demo is a service") {
t.Errorf("summary = %q", p.Summary)
}
if strings.Contains(p.Summary, "Install") {
t.Errorf("summary leaked a later section: %q", p.Summary)
}
}
func TestAnalyzeWithoutReadmeFallsBackToFacts(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
p, err := Analyze("bare", root)
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if p.Summary == "" {
t.Error("a project with no README still needs a summary")
}
if p.Stats.GoFiles != 1 {
t.Errorf("go files = %d", p.Stats.GoFiles)
}
}
func TestSignatureChangesOnlyWithTheTree(t *testing.T) {
root := fixture(t)
first, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
again, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
if first.Signature != again.Signature {
t.Errorf("signature is not stable: %q vs %q", first.Signature, again.Signature)
}
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
changed, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
if changed.Signature == first.Signature {
t.Error("signature did not change after a file was added")
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
func TestRenderProducesAudienceSections(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{
"# demo",
"## For Users",
"## For Operators",
"## For Developers",
"### HTTP API",
"| Method | Path | Handler |",
"| `GET` | `/healthz` | `health` |",
"### Direct dependencies",
"`github.com/go-chi/chi/v5` v5.2.3",
"### Network surface",
"`make build`",
} {
if !strings.Contains(page, want) {
t.Errorf("rendered page is missing %q", want)
}
}
}
func TestRenderIsWrappedInMarkers(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.HasPrefix(page, beginPrefix) {
t.Errorf("page does not start with the opening marker:\n%s", firstLine(page))
}
if !strings.HasSuffix(page, EndMarker) {
t.Errorf("page does not end with the closing marker:\n%s", lastLine(page))
}
if !FindBlock(page).Found {
t.Error("FindBlock cannot locate the markers on a freshly rendered page")
}
}
func TestRenderKeepsMarkdownSpacingSane(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
// Trim the marker lines: they are not part of the Markdown body.
body := page
if block := FindBlock(page); block.Found {
body = page[block.Body : block.End-len(EndMarker)]
}
lines := strings.Split(body, "\n")
for i := 0; i < len(lines)-1; i++ {
if lines[i] == "" && lines[i+1] == "" {
t.Errorf("blank line %d: two blank lines in a row", i+1)
}
if strings.HasPrefix(lines[i], "#") && lines[i+1] != "" {
t.Errorf("heading on line %d is glued to %q", i+1, lines[i+1])
}
}
if strings.HasPrefix(strings.TrimSpace(lines[0]), "<!--") {
t.Errorf("body starts with a comment: %q", lines[0])
}
}
func TestRenderIsDeterministic(t *testing.T) {
root := fixture(t)
first, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
second, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
a, err := Render(first)
if err != nil {
t.Fatal(err)
}
b, err := Render(second)
if err != nil {
t.Fatal(err)
}
// The only permitted difference is the generation timestamp.
if stampPattern.ReplaceAllString(a, "") != stampPattern.ReplaceAllString(b, "") {
t.Error("Render is not deterministic for an unchanged tree")
}
}
// ---------------------------------------------------------------------------
// Merging
// ---------------------------------------------------------------------------
func TestMergeCreatesWhenNoPageExists(t *testing.T) {
merged, status, err := Merge("", "generated body\n", false)
if err != nil {
t.Fatal(err)
}
if status != StatusCreated {
t.Errorf("status = %q", status)
}
if merged != "generated body\n" {
t.Errorf("merged = %q", merged)
}
}
func TestMergeSkipsUnmarkedPages(t *testing.T) {
const handWritten = "# demo\n\nWritten by a person.\n"
merged, status, err := Merge(handWritten, "generated\n", false)
if err != nil {
t.Fatal(err)
}
if status != StatusSkipped {
t.Errorf("status = %q", status)
}
if merged != handWritten {
t.Errorf("a hand-written page was modified: %q", merged)
}
}
func TestMergeForcesOverUnmarkedPages(t *testing.T) {
merged, status, err := Merge("# demo\n\nWritten by a person.\n", "generated\n", true)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q", status)
}
if merged != "generated\n" {
t.Errorf("merged = %q", merged)
}
}
func TestMergePreservesProseOutsideMarkers(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
withFooter := page + "\n## House rules\n\nNever edit the generated part by hand.\n"
// A second render of the same tree with different content, as if the
// project's README had been rewritten.
block := FindBlock(withFooter)
if !block.Found {
t.Fatal("markers missing from the page under test")
}
updated := strings.Replace(page, "Demo is a service", "Demo is a service, renamed", 1)
if updated == page {
t.Fatal("test setup failed to change the generated content")
}
merged, status, err := Merge(withFooter, updated, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q", status)
}
if !strings.Contains(merged, "Never edit the generated part by hand.") {
t.Error("hand-written footer was dropped")
}
if !strings.Contains(merged, "Demo is a service, renamed") {
t.Error("generated region was not replaced")
}
if got := strings.Count(merged, EndMarker); got != 1 {
t.Errorf("want exactly 1 closing marker, got %d", got)
}
if got := strings.Count(merged, beginPrefix); got != 1 {
t.Errorf("want exactly 1 opening marker, got %d", got)
}
}
func TestMergeReportsUnchangedAndKeepsTheFileStable(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
// Storage normalises the trailing newline, so the on-disk page has one.
onDisk := page + "\n"
merged, status, err := Merge(onDisk, page, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUnchanged {
t.Errorf("status = %q, want unchanged", status)
}
if merged != onDisk {
t.Error("an unchanged sync must return the file untouched, timestamp included")
}
}
func TestFindBlockIgnoresMalformedMarkers(t *testing.T) {
tests := map[string]string{
"no markers at all": "# demo\n\nBody.\n",
"only an opening marker": beginPrefix + " source=demo -->\n\nBody.\n",
"only a closing marker": "Body.\n" + EndMarker + "\n",
"marker not at line start": "text " + beginPrefix + " -->\nBody.\n" + EndMarker + "\n",
"markers out of order": EndMarker + "\n" + beginPrefix + " -->\n",
}
for name, content := range tests {
if block := FindBlock(content); block.Found {
t.Errorf("%s: expected no block, got %+v", name, block)
}
}
}
func TestFindBlockReportsBoundaries(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
block := FindBlock(page)
if !block.Found {
t.Fatal("block not found")
}
if block.Marker != 0 {
t.Errorf("marker offset = %d, want 0", block.Marker)
}
if !strings.HasPrefix(page[block.Body:], "# demo") {
t.Errorf("body does not start after the opening marker line: %q", page[block.Body:block.Body+8])
}
if got := page[block.End:]; got != "" {
t.Errorf("bytes after the block = %q, want none", got)
}
}
func TestWrapNormalisesTrailingNewlines(t *testing.T) {
page := Wrap("demo", "sig", "body\n\n\n")
if strings.HasSuffix(page, "\n") {
t.Error("Wrap must not end with a newline")
}
if !strings.HasSuffix(page, "\n"+EndMarker) {
t.Errorf("the closing marker must start its own line: %q", lastLine(page))
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func routeStrings(routes []Route) []string {
out := make([]string, 0, len(routes))
for _, route := range routes {
out = append(out, route.String())
}
return out
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func lastLine(s string) string {
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
return s[i+1:]
}
return s
}
// TestMergeIgnoresAMinuteOldTimestamp is the regression test for a bug that
// only showed up once a minute: two renders of an unchanged project carry
// different "generated=" stamps, and a pattern that stopped at the first space
// left "15:04 UTC" in place, so every sync in a new minute rewrote all four
// pages and reported "updated" for content that had not changed at all.
func TestMergeIgnoresAMinuteOldTimestamp(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
// Simulate a page imported an hour ago, down to the stamp.
stale := strings.Replace(page, "generated="+Timestamp(), "generated=2001-01-01 00:00 UTC", 1)
if stale == page {
t.Fatal("could not age the timestamp; the marker format changed")
}
merged, status, err := Merge(stale, page, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUnchanged {
t.Errorf("status = %q, want unchanged for an hour-old page with identical content", status)
}
if merged != stale {
t.Error("an unchanged sync must return the file untouched, timestamp included")
}
// A genuine content change must still be reported, even with an old stamp.
edited := strings.Replace(stale, "# demo", "# demo, revised", 1)
if edited == stale {
t.Fatal("could not edit the title")
}
merged, status, err = Merge(stale, edited, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q, want updated for a real content change", status)
}
if !strings.Contains(merged, "# demo, revised") {
t.Error("the edit did not reach the merged page")
}
}
// TestWrapStampIsFullyStripped guards the pattern itself: every byte of the
// timestamp has to be removed, not just the date.
func TestWrapStampIsFullyStripped(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
stripped := stampPattern.ReplaceAllString(page, stampReplacement)
if strings.Contains(stripped, " UTC") {
t.Errorf("the timestamp was not fully stripped: %q", firstLineOf(stripped))
}
if strings.Count(stripped, "-->") != strings.Count(page, "-->") {
t.Error("stripping the stamp swallowed more than the timestamp")
}
}
func firstLineOf(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}