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]), "\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 }