package changelog import ( "os/exec" "regexp" "strings" "testing" "github.com/carlosframework/platform/internal/docsite" ) // The gate over the committed docs/site/changelog.json. The generator // enforces all of this when it writes the file; these tests re-check // what was committed, so a hand edit or a stale file fails CI. const committedPath = "../../docs/site/changelog.json" func loadCommitted(t *testing.T) *Changelog { t.Helper() c, err := Load(committedPath) if err != nil { t.Fatal(err) } if len(c.Releases) == 0 { t.Fatal("changelog.json lists no releases") } return c } func TestCommittedShape(t *testing.T) { c := loadCommitted(t) if c.Repo != RepoURL { t.Errorf("repo = %q, want %q", c.Repo, RepoURL) } dayRe := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) seen := map[string]bool{} for i, r := range c.Releases { if !releaseTagRe.MatchString(r.Tag) || seen[r.Tag] { t.Errorf("tag %q is malformed or repeated", r.Tag) } seen[r.Tag] = true if i > 0 && !semverLess(r.Tag, c.Releases[i-1].Tag) { t.Errorf("%s follows %s: releases must run newest first", r.Tag, c.Releases[i-1].Tag) } if !dayRe.MatchString(r.Date) || r.SHA == "" || r.Changes == nil { t.Errorf("%s: date %q, sha %q, changes nil=%v", r.Tag, r.Date, r.SHA, r.Changes == nil) } if (r.Notes != nil && !r.Published) || r.Published != (r.NotesURL == NotesBase+r.Tag) { t.Errorf("%s: published=%v but notes=%v notes_url=%q", r.Tag, r.Published, r.Notes != nil, r.NotesURL) } // The page renders exactly these two forms; anything else would // drop the release's notes without a word. if n := r.Notes; n != nil { switch { case n.Form == "bullets" && n.MD == "" && len(n.Sections) > 0: for _, s := range n.Sections { if len(s.Bullets) == 0 { t.Errorf("%s: notes section %q has no bullets", r.Tag, s.Title) } for _, b := range s.Bullets { if strings.TrimSpace(b.MD) == "" { t.Errorf("%s: an empty bullet in %q", r.Tag, s.Title) } } } case n.Form == "markdown" && strings.TrimSpace(n.MD) != "" && len(n.Sections) == 0: default: t.Errorf("%s: notes form %q with %d sections and %d bytes of markdown is not one the page renders", r.Tag, n.Form, len(n.Sections), len(n.MD)) } } for _, ch := range r.Changes { if len(ch.SHA) != 40 || !dayRe.MatchString(ch.Date) || ch.Subject == "" || len(ch.Credit) == 0 { t.Errorf("%s: change %+v is missing a field", r.Tag, ch) } if ch.BranchURL != "" && (ch.Branch == "" || !strings.HasPrefix(ch.BranchURL, RepoURL+"/-/branch/")) { t.Errorf("%s: change %s links %q", r.Tag, short(ch.SHA), ch.BranchURL) } for _, d := range ch.Discussions { if d.ID <= 0 || d.Title == "" { t.Errorf("%s: change %s has discussion %+v", r.Tag, short(ch.SHA), d) } } } } } func TestCommittedIsPublic(t *testing.T) { people, err := LoadPeople("../../docs/site/people.json") if err != nil { t.Fatal(err) } if err := CheckPublic(loadCommitted(t), people); err != nil { t.Fatal(err) } } var mdDocsLinkRe = regexp.MustCompile(`\]\((/docs[^)\s]*)\)`) func TestCommittedDocsLinksResolve(t *testing.T) { site, err := docsite.Load("../../docs/site") if err != nil { t.Fatal(err) } c := loadCommitted(t) check := func(where, href string) { if err := resolveDocs(site, href); err != nil { t.Errorf("%s: %s: %v", where, href, err) } } for _, r := range c.Releases { if r.Notes != nil { for _, m := range mdDocsLinkRe.FindAllStringSubmatch(r.Notes.MD, -1) { check(r.Tag+" notes", m[1]) } for _, s := range r.Notes.Sections { for _, b := range s.Bullets { for _, d := range b.Docs { check(r.Tag+" bullet", d) } for _, m := range mdDocsLinkRe.FindAllStringSubmatch(b.MD, -1) { check(r.Tag+" bullet", m[1]) } } } } for _, ch := range r.Changes { for _, d := range ch.Docs { check(r.Tag+" change "+short(ch.SHA), d) } } } } // resolveDocs reports whether the website will serve href: the page // exists and, with a fragment, the page has a heading the website // anchors (levels two to four) with that id. func resolveDocs(site *docsite.Site, href string) error { slug, frag, _ := strings.Cut(strings.TrimPrefix(href, "/docs"), "#") slug = strings.Trim(slug, "/") if slug == "" { slug = "index" } p := site.BySlug[slug] if p == nil { return errNoPage } if frag == "" { return nil } for _, h := range p.Headings { if anchored(h) && h.Anchor == frag { return nil } } return errNoAnchor } type gateErr string func (e gateErr) Error() string { return string(e) } const ( errNoPage gateErr = "no such docs page" errNoAnchor gateErr = "no heading the website anchors with that id" ) func TestCommittedBulletLinks(t *testing.T) { links, err := LoadLinks("../../docs/site/changelog-links.json") if err != nil { t.Fatal(err) } c := loadCommitted(t) byTag := map[string]Release{} for _, r := range c.Releases { byTag[r.Tag] = r } for tag, byLead := range links { r, ok := byTag[tag] if !ok { t.Errorf("changelog-links.json names %s, which is not in changelog.json", tag) continue } branches := map[string]bool{} for _, ch := range r.Changes { branches[ch.Branch] = true } for lead, want := range byLead { var found *Bullet for _, s := range sections(r) { for i, b := range s.Bullets { if m := leadRe.FindStringSubmatch(b.MD); m != nil && m[1] == lead { found = &s.Bullets[i] } } } if found == nil { t.Errorf("%s: no bullet leads with %q", tag, lead) continue } if strings.Join(found.Branches, ",") != strings.Join(want, ",") { t.Errorf("%s %q: branches %v, changelog-links.json says %v: regenerate", tag, lead, found.Branches, want) } for _, br := range want { if !branches[br] { t.Errorf("%s %q: links %s, which did not land in %s", tag, lead, br, tag) } } } } } func sections(r Release) []NoteSection { if r.Notes == nil { return nil } return r.Notes.Sections } // TestCommittedIsComplete checks every release and every first-parent // commit against git. It needs the tags, so a shallow clone without them // skips it; a full checkout (where the release recipe regenerates the // file) runs it. A tag newer than the newest release in the file is a // release not regenerated yet, which is allowed. func TestCommittedIsComplete(t *testing.T) { out, err := exec.Command("git", "-C", "../..", "tag", "-l", "v*").Output() if err != nil || strings.TrimSpace(string(out)) == "" { t.Skip("no release tags in this checkout; completeness is checked where they are fetched") } if shallow, _ := exec.Command("git", "-C", "../..", "rev-parse", "--is-shallow-repository").Output(); strings.TrimSpace(string(shallow)) == "true" { t.Skip("shallow checkout; completeness is checked in a full one") } g, err := NewExecGit("../..") if err != nil { t.Fatal(err) } tags, err := g.Tags() if err != nil { t.Fatal(err) } c := loadCommitted(t) newest := c.Releases[0].Tag inFile := map[string]Release{} for _, r := range c.Releases { inFile[r.Tag] = r } for _, tag := range tags { if _, ok := inFile[tag.Name]; !ok { if semverLess(newest, tag.Name) { t.Logf("%s is newer than the changelog: regenerate it as the release recipe says", tag.Name) continue } t.Errorf("tag %s is missing from changelog.json", tag.Name) } } var earlier []Release for i := len(c.Releases) - 1; i >= 0; i-- { r := c.Releases[i] commits, err := g.FirstParent(r.Tag, RangeBases(r.Published, earlier)) if err != nil { t.Fatalf("%s: %v", r.Tag, err) } var want, got []string for _, cm := range commits { want = append(want, cm.SHA) } for _, ch := range r.Changes { got = append(got, ch.SHA) } if strings.Join(got, " ") != strings.Join(want, " ") { t.Errorf("%s: changes %d, git has %d first-parent commits for it: regenerate", r.Tag, len(got), len(want)) } earlier = append(earlier, r) } }