220 lines
7.6 KiB
Go
220 lines
7.6 KiB
Go
// This file holds the end-to-end suite for the documentation importer: analyse a
|
|
// real project tree on disk, render a page, merge it through the storage engine,
|
|
// and read it back over HTTP. No mocks anywhere -- if the pipeline breaks, these
|
|
// tests break.
|
|
package tests
|
|
|
|
import (
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go-doc/docgen"
|
|
)
|
|
|
|
// getJSON fetches a resource as JSON, which is how the frontend reads pages.
|
|
// The API negotiates with ?format=json rather than the Accept header.
|
|
func (h *harness) getJSON(path string) map[string]any {
|
|
h.t.Helper()
|
|
if !strings.Contains(path, "?") {
|
|
path += "?format=json"
|
|
}
|
|
payload := h.expectStatus(http.MethodGet, path, "", "", http.StatusOK)
|
|
return decodeJSON[map[string]any](h.t, payload)
|
|
}
|
|
|
|
func TestIntegrationDocgenImport(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
|
|
workspace := t.TempDir()
|
|
writeFixtureProject(t, filepath.Join(workspace, "go-fixture"))
|
|
|
|
project, err := docgen.Analyze("go-fixture", filepath.Join(workspace, "go-fixture"))
|
|
if err != nil {
|
|
t.Fatalf("integration: analyse fixture: %v", err)
|
|
}
|
|
page, err := docgen.Render(project)
|
|
if err != nil {
|
|
t.Fatalf("integration: render fixture: %v", err)
|
|
}
|
|
|
|
slug, err := h.engine.SaveDoc(project.Name, page)
|
|
if err != nil {
|
|
t.Fatalf("integration: import page: %v", err)
|
|
}
|
|
if slug != "go-fixture" {
|
|
t.Fatalf("integration: expected slug go-fixture, got %q", slug)
|
|
}
|
|
|
|
// The page lands on disk exactly once, wrapped in generated markers that
|
|
// record which project it came from.
|
|
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "go-fixture.md")))
|
|
if !strings.HasPrefix(onDisk, "<!-- go-doc:generated:begin") {
|
|
t.Errorf("imported page is missing its opening marker:\n%s", firstLineOf(onDisk))
|
|
}
|
|
if !strings.Contains(onDisk, "source=go-fixture") {
|
|
t.Errorf("imported page does not record its source project:\n%s", firstLineOf(onDisk))
|
|
}
|
|
if strings.Count(onDisk, "go-doc:generated:end") != 1 {
|
|
t.Errorf("imported page has %d closing markers, want 1",
|
|
strings.Count(onDisk, "go-doc:generated:end"))
|
|
}
|
|
|
|
// The API serves it under the title the index shows.
|
|
doc := h.getJSON("/api/docs/go-fixture")
|
|
if doc["title"] != "go-fixture" {
|
|
t.Errorf("title = %v, want go-fixture", doc["title"])
|
|
}
|
|
content, _ := doc["content"].(string)
|
|
if content != onDisk {
|
|
t.Error("API content differs from the file on disk")
|
|
}
|
|
|
|
// Every audience tab the reader keys off is present, in order.
|
|
users := strings.Index(content, "## For Users")
|
|
operators := strings.Index(content, "## For Operators")
|
|
developers := strings.Index(content, "## For Developers")
|
|
if users < 0 || operators < 0 || developers < 0 {
|
|
t.Fatalf("audience sections missing: %d %d %d", users, operators, developers)
|
|
}
|
|
if !(users < operators && operators < developers) {
|
|
t.Errorf("audience sections are out of order: %d %d %d", users, operators, developers)
|
|
}
|
|
|
|
// Facts extracted from the source reached the page.
|
|
for _, want := range []string{"| `GET` | `/healthz` | `health` |", "`DEMO_HTTP_PORT`", "`make build`"} {
|
|
if !strings.Contains(content, want) {
|
|
t.Errorf("imported page is missing extracted fact %q", want)
|
|
}
|
|
}
|
|
|
|
// It appears in the index.
|
|
index := h.getJSON("/api/docs")
|
|
docs, _ := index["docs"].([]any)
|
|
if len(docs) != 1 {
|
|
t.Fatalf("expected 1 indexed document, got %d", len(docs))
|
|
}
|
|
entry, _ := docs[0].(map[string]any)
|
|
if entry["slug"] != "go-fixture" {
|
|
t.Errorf("index slug = %v, want go-fixture", entry["slug"])
|
|
}
|
|
|
|
// A comment attaches to a generated page like any other document.
|
|
h.expectStatus(http.MethodPost, "/api/docs/go-fixture/comments",
|
|
"application/json",
|
|
`{"role":"operator","author":"sre","content":"The port table needs the compose port too."}`,
|
|
http.StatusCreated)
|
|
stats := h.getJSON("/api/docs/go-fixture/stats")
|
|
if stats["total"] != float64(1) {
|
|
t.Errorf("comment count = %v, want 1", stats["total"])
|
|
}
|
|
|
|
// Re-importing identical content changes nothing, and prose a human added
|
|
// outside the markers survives the sync.
|
|
pagePath := filepath.Join(h.docsDir, "go-fixture.md")
|
|
withFooter := onDisk + "\n## Notes from the team\n\nCheck the runbook before deploying.\n"
|
|
if err := os.WriteFile(pagePath, []byte(withFooter), 0o644); err != nil {
|
|
t.Fatalf("integration: write page: %v", err)
|
|
}
|
|
|
|
merged, status, err := docgen.Merge(withFooter, page, false)
|
|
if err != nil {
|
|
t.Fatalf("integration: re-merge: %v", err)
|
|
}
|
|
if status != docgen.StatusUnchanged {
|
|
t.Errorf("re-importing an unchanged project reported %q, want unchanged", status)
|
|
}
|
|
if _, err := h.engine.SaveDoc(project.Name, merged); err != nil {
|
|
t.Fatalf("integration: re-import: %v", err)
|
|
}
|
|
if reread := string(requireFile(t, pagePath)); !strings.Contains(reread, "Check the runbook before deploying.") {
|
|
t.Error("re-import dropped hand-written prose")
|
|
}
|
|
}
|
|
|
|
// TestIntegrationDocgenProtectsHandWrittenPages proves the importer never
|
|
// overwrites a document that it did not generate.
|
|
func TestIntegrationDocgenProtectsHandWrittenPages(t *testing.T) {
|
|
t.Parallel()
|
|
h := newHarness(t)
|
|
|
|
const handWritten = "# go-doc\n\nWritten by a person, with no generated markers.\n"
|
|
if _, err := h.engine.SaveDoc("go-doc", handWritten); err != nil {
|
|
t.Fatalf("integration: seed hand-written page: %v", err)
|
|
}
|
|
|
|
generated, err := docgen.Render(&docgen.Project{Name: "go-doc", Summary: "Machine written."})
|
|
if err != nil {
|
|
t.Fatalf("integration: render: %v", err)
|
|
}
|
|
|
|
pagePath := filepath.Join(h.docsDir, "go-doc.md")
|
|
existing := string(requireFile(t, pagePath))
|
|
|
|
merged, status, err := docgen.Merge(existing, generated, false)
|
|
if err != nil {
|
|
t.Fatalf("integration: merge: %v", err)
|
|
}
|
|
if status != docgen.StatusSkipped {
|
|
t.Errorf("merge status = %q, want skipped for an unmarked page", status)
|
|
}
|
|
if merged != existing {
|
|
t.Error("a hand-written page was modified by the importer")
|
|
}
|
|
if _, err := h.engine.SaveDoc("go-doc", merged); err != nil {
|
|
t.Fatalf("integration: save: %v", err)
|
|
}
|
|
if reread := string(requireFile(t, pagePath)); reread != handWritten {
|
|
t.Errorf("hand-written page changed on disk:\n%s", reread)
|
|
}
|
|
}
|
|
|
|
// TestIntegrationDocgenMissingProjectIsReportedNotIgnored covers the case the
|
|
// importer must never paper over: a project directory that cannot be analysed.
|
|
func TestIntegrationDocgenMissingProjectIsReportedNotIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
workspace := t.TempDir()
|
|
if _, err := docgen.Analyze("go-absent", filepath.Join(workspace, "go-absent")); err == nil {
|
|
t.Fatal("analysing a missing project should fail loudly")
|
|
}
|
|
}
|
|
|
|
// writeFixtureProject lays out a small project exercising the extractors the
|
|
// importer depends on: routes, flags, environment, a module, a Makefile and a
|
|
// README.
|
|
func writeFixtureProject(t *testing.T, root string) {
|
|
t.Helper()
|
|
|
|
files := map[string]string{
|
|
"README.md": "# Demo Service\n\nDemo answers questions and exposes a JSON API.\n",
|
|
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire github.com/go-chi/chi/v5 v5.2.3\n",
|
|
"Makefile": "build:\n\tgo build ./...\n",
|
|
".env.example": "DEMO_HTTP_PORT=8080\n",
|
|
"internal/server/routes.go": "package server\n\n" +
|
|
"import \"net/http\"\n\n" +
|
|
"func Routes(mux *http.ServeMux) {\n" +
|
|
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
|
|
"\tmux.HandleFunc(\"/api/items\", listItems)\n}\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("integration: mkdir %s: %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("integration: write %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func firstLineOf(s string) string {
|
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
return s[:i]
|
|
}
|
|
return s
|
|
}
|