carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package changelog
2
3import (
4 "os/exec"
5 "regexp"
6 "strings"
7 "testing"
8
9 "github.com/carlosframework/platform/internal/docsite"
10)
11
12// The gate over the committed docs/site/changelog.json. The generator
13// enforces all of this when it writes the file; these tests re-check
14// what was committed, so a hand edit or a stale file fails CI.
15
16const committedPath = "../../docs/site/changelog.json"
17
18func loadCommitted(t *testing.T) *Changelog {
19 t.Helper()
20 c, err := Load(committedPath)
21 if err != nil {
22 t.Fatal(err)
23 }
24 if len(c.Releases) == 0 {
25 t.Fatal("changelog.json lists no releases")
26 }
27 return c
28}
29
30func TestCommittedShape(t *testing.T) {
31 c := loadCommitted(t)
32 if c.Repo != RepoURL {
33 t.Errorf("repo = %q, want %q", c.Repo, RepoURL)
34 }
35 dayRe := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
36 seen := map[string]bool{}
37 for i, r := range c.Releases {
38 if !releaseTagRe.MatchString(r.Tag) || seen[r.Tag] {
39 t.Errorf("tag %q is malformed or repeated", r.Tag)
40 }
41 seen[r.Tag] = true
42 if i > 0 && !semverLess(r.Tag, c.Releases[i-1].Tag) {
43 t.Errorf("%s follows %s: releases must run newest first", r.Tag, c.Releases[i-1].Tag)
44 }
45 if !dayRe.MatchString(r.Date) || r.SHA == "" || r.Changes == nil {
46 t.Errorf("%s: date %q, sha %q, changes nil=%v", r.Tag, r.Date, r.SHA, r.Changes == nil)
47 }
48 if (r.Notes != nil && !r.Published) || r.Published != (r.NotesURL == NotesBase+r.Tag) {
49 t.Errorf("%s: published=%v but notes=%v notes_url=%q", r.Tag, r.Published, r.Notes != nil, r.NotesURL)
50 }
51 // The page renders exactly these two forms; anything else would
52 // drop the release's notes without a word.
53 if n := r.Notes; n != nil {
54 switch {
55 case n.Form == "bullets" && n.MD == "" && len(n.Sections) > 0:
56 for _, s := range n.Sections {
57 if len(s.Bullets) == 0 {
58 t.Errorf("%s: notes section %q has no bullets", r.Tag, s.Title)
59 }
60 for _, b := range s.Bullets {
61 if strings.TrimSpace(b.MD) == "" {
62 t.Errorf("%s: an empty bullet in %q", r.Tag, s.Title)
63 }
64 }
65 }
66 case n.Form == "markdown" && strings.TrimSpace(n.MD) != "" && len(n.Sections) == 0:
67 default:
68 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))
69 }
70 }
71 for _, ch := range r.Changes {
72 if len(ch.SHA) != 40 || !dayRe.MatchString(ch.Date) || ch.Subject == "" || len(ch.Credit) == 0 {
73 t.Errorf("%s: change %+v is missing a field", r.Tag, ch)
74 }
75 if ch.BranchURL != "" && (ch.Branch == "" || !strings.HasPrefix(ch.BranchURL, RepoURL+"/-/branch/")) {
76 t.Errorf("%s: change %s links %q", r.Tag, short(ch.SHA), ch.BranchURL)
77 }
78 for _, d := range ch.Discussions {
79 if d.ID <= 0 || d.Title == "" {
80 t.Errorf("%s: change %s has discussion %+v", r.Tag, short(ch.SHA), d)
81 }
82 }
83 }
84 }
85}
86
87func TestCommittedIsPublic(t *testing.T) {
88 people, err := LoadPeople("../../docs/site/people.json")
89 if err != nil {
90 t.Fatal(err)
91 }
92 if err := CheckPublic(loadCommitted(t), people); err != nil {
93 t.Fatal(err)
94 }
95}
96
97var mdDocsLinkRe = regexp.MustCompile(`\]\((/docs[^)\s]*)\)`)
98
99func TestCommittedDocsLinksResolve(t *testing.T) {
100 site, err := docsite.Load("../../docs/site")
101 if err != nil {
102 t.Fatal(err)
103 }
104 c := loadCommitted(t)
105 check := func(where, href string) {
106 if err := resolveDocs(site, href); err != nil {
107 t.Errorf("%s: %s: %v", where, href, err)
108 }
109 }
110 for _, r := range c.Releases {
111 if r.Notes != nil {
112 for _, m := range mdDocsLinkRe.FindAllStringSubmatch(r.Notes.MD, -1) {
113 check(r.Tag+" notes", m[1])
114 }
115 for _, s := range r.Notes.Sections {
116 for _, b := range s.Bullets {
117 for _, d := range b.Docs {
118 check(r.Tag+" bullet", d)
119 }
120 for _, m := range mdDocsLinkRe.FindAllStringSubmatch(b.MD, -1) {
121 check(r.Tag+" bullet", m[1])
122 }
123 }
124 }
125 }
126 for _, ch := range r.Changes {
127 for _, d := range ch.Docs {
128 check(r.Tag+" change "+short(ch.SHA), d)
129 }
130 }
131 }
132}
133
134// resolveDocs reports whether the website will serve href: the page
135// exists and, with a fragment, the page has a heading the website
136// anchors (levels two to four) with that id.
137func resolveDocs(site *docsite.Site, href string) error {
138 slug, frag, _ := strings.Cut(strings.TrimPrefix(href, "/docs"), "#")
139 slug = strings.Trim(slug, "/")
140 if slug == "" {
141 slug = "index"
142 }
143 p := site.BySlug[slug]
144 if p == nil {
145 return errNoPage
146 }
147 if frag == "" {
148 return nil
149 }
150 for _, h := range p.Headings {
151 if anchored(h) && h.Anchor == frag {
152 return nil
153 }
154 }
155 return errNoAnchor
156}
157
158type gateErr string
159
160func (e gateErr) Error() string { return string(e) }
161
162const (
163 errNoPage gateErr = "no such docs page"
164 errNoAnchor gateErr = "no heading the website anchors with that id"
165)
166
167func TestCommittedBulletLinks(t *testing.T) {
168 links, err := LoadLinks("../../docs/site/changelog-links.json")
169 if err != nil {
170 t.Fatal(err)
171 }
172 c := loadCommitted(t)
173 byTag := map[string]Release{}
174 for _, r := range c.Releases {
175 byTag[r.Tag] = r
176 }
177 for tag, byLead := range links {
178 r, ok := byTag[tag]
179 if !ok {
180 t.Errorf("changelog-links.json names %s, which is not in changelog.json", tag)
181 continue
182 }
183 branches := map[string]bool{}
184 for _, ch := range r.Changes {
185 branches[ch.Branch] = true
186 }
187 for lead, want := range byLead {
188 var found *Bullet
189 for _, s := range sections(r) {
190 for i, b := range s.Bullets {
191 if m := leadRe.FindStringSubmatch(b.MD); m != nil && m[1] == lead {
192 found = &s.Bullets[i]
193 }
194 }
195 }
196 if found == nil {
197 t.Errorf("%s: no bullet leads with %q", tag, lead)
198 continue
199 }
200 if strings.Join(found.Branches, ",") != strings.Join(want, ",") {
201 t.Errorf("%s %q: branches %v, changelog-links.json says %v: regenerate", tag, lead, found.Branches, want)
202 }
203 for _, br := range want {
204 if !branches[br] {
205 t.Errorf("%s %q: links %s, which did not land in %s", tag, lead, br, tag)
206 }
207 }
208 }
209 }
210}
211
212func sections(r Release) []NoteSection {
213 if r.Notes == nil {
214 return nil
215 }
216 return r.Notes.Sections
217}
218
219// TestCommittedIsComplete checks every release and every first-parent
220// commit against git. It needs the tags, so a shallow clone without them
221// skips it; a full checkout (where the release recipe regenerates the
222// file) runs it. A tag newer than the newest release in the file is a
223// release not regenerated yet, which is allowed.
224func TestCommittedIsComplete(t *testing.T) {
225 out, err := exec.Command("git", "-C", "../..", "tag", "-l", "v*").Output()
226 if err != nil || strings.TrimSpace(string(out)) == "" {
227 t.Skip("no release tags in this checkout; completeness is checked where they are fetched")
228 }
229 if shallow, _ := exec.Command("git", "-C", "../..", "rev-parse", "--is-shallow-repository").Output(); strings.TrimSpace(string(shallow)) == "true" {
230 t.Skip("shallow checkout; completeness is checked in a full one")
231 }
232 g, err := NewExecGit("../..")
233 if err != nil {
234 t.Fatal(err)
235 }
236 tags, err := g.Tags()
237 if err != nil {
238 t.Fatal(err)
239 }
240 c := loadCommitted(t)
241 newest := c.Releases[0].Tag
242 inFile := map[string]Release{}
243 for _, r := range c.Releases {
244 inFile[r.Tag] = r
245 }
246 for _, tag := range tags {
247 if _, ok := inFile[tag.Name]; !ok {
248 if semverLess(newest, tag.Name) {
249 t.Logf("%s is newer than the changelog: regenerate it as the release recipe says", tag.Name)
250 continue
251 }
252 t.Errorf("tag %s is missing from changelog.json", tag.Name)
253 }
254 }
255 var earlier []Release
256 for i := len(c.Releases) - 1; i >= 0; i-- {
257 r := c.Releases[i]
258 commits, err := g.FirstParent(r.Tag, RangeBases(r.Published, earlier))
259 if err != nil {
260 t.Fatalf("%s: %v", r.Tag, err)
261 }
262 var want, got []string
263 for _, cm := range commits {
264 want = append(want, cm.SHA)
265 }
266 for _, ch := range r.Changes {
267 got = append(got, ch.SHA)
268 }
269 if strings.Join(got, " ") != strings.Join(want, " ") {
270 t.Errorf("%s: changes %d, git has %d first-parent commits for it: regenerate", r.Tag, len(got), len(want))
271 }
272 earlier = append(earlier, r)
273 }
274}
275