Repository navigation
Expand file tree
/
Copy pathsurface_test.go
More file actions
323 lines (309 loc) · 14.8 KB
/
Copy pathsurface_test.go
File metadata and controls
323 lines (309 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
package forgeapi_test
import (
"bytes"
"cmp"
"go/ast"
"go/parser"
"go/token"
"os"
"strings"
"testing"
"github.com/cplieger/forgeapi/internal/surface"
)
const (
goldenPath = "api/forgeapi.txt"
regenerate = "UPDATE_GOLDEN=1 go test ./... -run TestExportedSurfaceMatchesGolden"
)
// TestExportedSurfaceMatchesGolden regenerates the surface from the current
// source and fails on any drift, so a change to an exported declaration cannot
// land without the golden file moving with it.
//
// The golden file is the reviewed assertion and its diff is the review surface:
// a normal run never writes it.
func TestExportedSurfaceMatchesGolden(t *testing.T) {
got, err := surface.Generate(".")
if err != nil {
t.Fatalf("Setup: surface.Generate(%q): %v", ".", err)
}
if os.Getenv("UPDATE_GOLDEN") == "1" {
if err := os.WriteFile(goldenPath, got, 0o600); err != nil {
t.Fatalf("Setup: writing %s: %v", goldenPath, err)
}
// A regenerating run FAILS, deliberately: it wrote the file it is
// supposed to be comparing against, so reporting success would make
// the gate disarmable by one environment variable, and a run with
// UPDATE_GOLDEN set anywhere in the environment would be
// indistinguishable from a surface that had not drifted.
t.Fatalf("regenerated %s, %d bytes: review the diff and re-run without UPDATE_GOLDEN to gate the surface", goldenPath, len(got))
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("Setup: reading %s (regenerate with %s): %v", goldenPath, regenerate, err)
}
if bytes.Equal(got, want) {
return
}
line, gotLine, wantLine := firstDifference(string(got), string(want))
t.Errorf("surface.Generate(%q) differs from %s at line %d:\n got: %s\n want: %s\nregenerate with: %s",
".", goldenPath, line, gotLine, wantLine, regenerate)
}
// TestSurfaceIsNotVacuous pins a handful of declarations the generator must
// find, so a generator that silently stopped emitting cannot pass by matching an
// equally empty golden file. Every package a consumer can import is named here,
// which is what makes "the golden covers the whole surface" a checked claim
// rather than an incidental property of a tree walk.
func TestSurfaceIsNotVacuous(t *testing.T) {
got, err := surface.Generate(".")
if err != nil {
t.Fatalf("Setup: surface.Generate(%q): %v", ".", err)
}
rendered := string(got)
for _, want := range []string{
"## package forgeapi (./.)",
"## package creds (./creds)",
"## package families (./families)",
"## package gitcred (./gitcred)",
"## package gitea (./gitea)",
"## package github (./github)",
"## package gitlab (./gitlab)",
"## package readcache (./readcache)",
"func NewTransport(next http.RoundTripper, opts ...Option) (*Transport, error)",
"type Core interface {",
"type Support int",
"const SupportUnknown Support = iota",
"const SupportNo Support = iota + 2",
"type Page[T any] struct {",
"func DecodeRepoRef(id string, family Family) (RepoRef, error)",
"func (r RepoRef) Encode() string",
"func WithRotationCursor(c RotationCursor) Option",
"\tRotationCursor RotationCursor\n",
// The two fields CodeCapabilityUnsupported's payload is made of. They
// are named here because that refusal is ruled to CARRY the
// capability and the evidence: a struct that lost them
// would still satisfy every other test on this surface, and the
// refusal would degrade to a bare code with the fact in prose.
"\tCapability Capability\n",
"\tEvidence Evidence\n",
"func DefaultBudget() Budget",
"\tHeaders []Header\n",
"func ReservedHeaders() []string",
"MergePR(ctx context.Context, repo RepoRef, pr PRRef, req MergeRequest) (MergeOutcome, error)",
"func Open(ctx context.Context, conn forgeapi.Connection, opts ...forgeapi.Option) (forgeapi.Core, forgeapi.Family, error)",
"func (c *Client) MergePR(ctx context.Context, repo forgeapi.RepoRef, pr forgeapi.PRRef, req forgeapi.MergeRequest) (forgeapi.MergeOutcome, error)",
"func NewSource(store Store, key string, conn forgeapi.Connection, opts ...forgeapi.Option) (*Source, error)",
"func New(store creds.Store, opts ...forgeapi.Option) *Helper",
} {
if !strings.Contains(rendered, want) {
t.Errorf("surface.Generate(%q) does not render %q", ".", want)
}
}
}
// TestFamilyPackagesExportNoTypeButTheClient is the mechanical form of the rule
// that a family's own types stay inside it: the normalizers, the wire shapes and
// the family's own vocabulary are facts about one upstream API, and the day one of
// them is exported it becomes something a consumer can name and this library has
// to keep.
//
// The complementary half needs no test: the root package cannot import a family
// package without a cycle, so no family type can reach the root's surface at all.
func TestFamilyPackagesExportNoTypeButTheClient(t *testing.T) {
got, err := surface.Generate(".")
if err != nil {
t.Fatalf("Setup: surface.Generate(%q): %v", ".", err)
}
for _, pkg := range []string{"github", "gitlab", "gitea"} {
section := packageSection(string(got), pkg)
if section == "" {
t.Errorf("surface.Generate(%q) renders no section for package %s", ".", pkg)
continue
}
for line := range strings.SplitSeq(section, "\n") {
name, ok := typeName(line)
if !ok {
continue
}
if name != "Client" {
t.Errorf("package %s declares exported type %q, want no exported type but Client: a family's own types stay inside the family package", pkg, name)
}
}
}
}
// typeName returns the type name a rendered declaration line declares, and
// reports whether the line declares one at all.
//
// It reads the identifier rather than comparing the whole line, because the
// rendering of one type is not stable under a change that is nobody's business
// here: a struct with no members renders as "type Client struct{}" and one with
// unexported members as "type Client struct {", so a line comparison would
// accuse a family package of exporting some other type on the day Client's last
// field moves into a shared core.
func typeName(line string) (string, bool) {
rest, ok := strings.CutPrefix(line, "type ")
if !ok {
return "", false
}
name := rest
if i := strings.IndexAny(name, " [{="); i >= 0 {
name = name[:i]
}
return name, name != ""
}
// TestNoExportedTypeEmbedsAnUnrenderedType closes the one drift class the golden
// file cannot see on its own.
//
// An embedded type promotes ITS exported methods onto the embedding type, and the
// generator renders declarations, so where the embedded type's own methods are
// rendered nowhere they reach a consumer while every declaration in the golden
// stays byte for byte the same. Both shapes are measured. An unexported core
// carrying RawDo left the golden identical and all gates green, which is how the
// Raw() escape hatch this design forbids would arrive; and an EXPORTED type in an
// internal package did the same, which is the shape a transport core shared by
// three family packages has to take, since it must be nameable there. The
// interface half is the sharpest of the three: a method added to an interface a
// consumer implements breaks every implementer, and an unrendered embed is the one
// way that addition leaves no trace here.
//
// The generator renders such an embed as its own line, so its arrival is drift;
// this names the failure rather than leaving a reader to interpret the diff. When
// a shared core does land, the promoted surface is what has to be reviewed here,
// not the line.
func TestNoExportedTypeEmbedsAnUnrenderedType(t *testing.T) {
got, err := surface.Generate(".")
if err != nil {
t.Fatalf("Setup: surface.Generate(%q): %v", ".", err)
}
for line := range strings.SplitSeq(string(got), "\n") {
trimmed := strings.TrimPrefix(line, "\t")
if strings.HasPrefix(trimmed, "// embeds ") {
t.Errorf("surface.Generate(%q) renders %q: an exported type embeds a type whose own methods this generator does not render, so a method added to it promotes onto the surface with the golden unchanged", ".", trimmed)
}
}
}
// packageSection returns the rendered surface of one package, empty when the
// rendering holds no section for it.
func packageSection(rendered, pkg string) string {
head := "\n## package " + pkg + " (./" + pkg + ")\n"
_, rest, found := strings.Cut(rendered, head)
if !found {
return ""
}
if section, _, more := strings.Cut(rest, "\n## package "); more {
return section
}
return rest
}
// TestRuledDefaultsAreStated pins, for every option with a ruled default, what the
// surface golden cannot see because it omits doc comments: the one operative
// "Default:" line opening with the ruled value or constant, the ruled meaning of a
// zero where one is ruled, and the codes that enforce it.
func TestRuledDefaultsAreStated(t *testing.T) {
const options = "options.go"
const defaultPrefix = "Default:"
for _, test := range []struct {
file string
fn string
defaultClause string
zeroClause string
names []string
}{
{fn: "WithMutations", defaultClause: "Default: true.", names: []string{"CodeMutationsDisabled"}},
{fn: "WithPrivateAddresses", defaultClause: "Default: false."},
{fn: "WithPlaintextHTTP", defaultClause: "Default: false."},
{fn: "WithListPages", defaultClause: "Default: [DefaultListPages].", zeroClause: "Zero traverses none."},
{fn: "WithStatusPages", defaultClause: "Default: [DefaultStatusPages].", zeroClause: "Zero traverses none."},
{fn: "WithStatusReadsPerInterval", defaultClause: "Default: [DefaultStatusReadsPerInterval].", zeroClause: "Zero issues none,"},
{fn: "WithRetries", defaultClause: "Default: [DefaultRetries].", zeroClause: "Zero means no retries,"},
{fn: "WithReadConcurrency", defaultClause: "Default: [DefaultReadConcurrency].", zeroClause: "Zero admits no read at all, so it is REFUSED", names: []string{"CodeBudgetInvalid"}},
{fn: "WithMutationConcurrency", defaultClause: "Default: [DefaultMutationConcurrency].", zeroClause: "Zero admits no mutation at all, so it is REFUSED", names: []string{"CodeBudgetInvalid"}},
{fn: "WithOperationTimeout", defaultClause: "Default: [DefaultOperationTimeout].", zeroClause: "Zero is a deadline that has already passed."},
{fn: "WithStatusTimePerInterval", defaultClause: "Default: [DefaultStatusTimePerInterval].", zeroClause: "Zero allows none."},
{fn: "WithMutationInterval", defaultClause: "Default: [DefaultMutationInterval].", zeroClause: "Zero leaves no gap,"},
{fn: "WithMutationReserve", defaultClause: "Default: [DefaultMutationReserve].", zeroClause: "Zero holds nothing back,", names: []string{"CodeBudgetInvalid"}},
{fn: "WithRotationCursor", defaultClause: "Default: the empty cursor,", names: []string{"CodeCursorInvalid"}},
{fn: "WithCredentialSource", defaultClause: "Default: none,"},
{fn: "WithRefreshLead", defaultClause: "Default: [DefaultRefreshLead].", zeroClause: "Zero refreshes a token only once it has expired.", names: []string{"CodeBudgetInvalid"}},
{fn: "WithPageBound", defaultClause: "Default: [DefaultPageBound].", names: []string{"CodePageBoundInvalid"}},
{fn: "WithSince", defaultClause: "Default: no bound,", names: []string{"CodeListSinceInvalid"}},
{fn: "WithReadCache", defaultClause: "Default: [DefaultReadCacheBytes].", zeroClause: "Zero holds nothing and sends every read unconditionally.", names: []string{"CodeBudgetInvalid"}},
{file: "readcache/transport.go", fn: "WithMaxBytes", defaultClause: "Default: [forgeapi.DefaultReadCacheBytes].", zeroClause: "Zero holds nothing and adds no validator,", names: []string{"CodeBudgetInvalid"}},
{file: "readcache/transport.go", fn: "WithFreeNotModified", defaultClause: "Default: false,"},
{file: "readcache/transport.go", fn: "WithHeldFields", defaultClause: "Default: none."},
} {
t.Run(test.fn, func(t *testing.T) {
file := cmp.Or(test.file, options)
doc := funcDoc(t, file, test.fn)
var stated []string
for line := range strings.SplitSeq(doc, "\n") {
if strings.HasPrefix(line, defaultPrefix) {
stated = append(stated, line)
}
}
if n := strings.Count(doc, defaultPrefix); len(stated) != 1 || n != 1 {
t.Fatalf("funcDoc(%q, %q) states %q %d time(s), %d of them opening a line, want exactly once, opening a line and stating %q: a second one is a contradiction a reader resolves by guessing", file, test.fn, defaultPrefix, n, len(stated), test.defaultClause)
}
if !strings.HasPrefix(stated[0], test.defaultClause) {
t.Errorf("funcDoc(%q, %q) states %q, want it to open with %q: this default cannot be changed compatibly after 1.0", file, test.fn, stated[0], test.defaultClause)
}
for _, want := range test.names {
if !strings.Contains(doc, want) {
t.Errorf("funcDoc(%q, %q) does not contain %q, want it stated: the surface file cannot pin a doc comment, so this assertion is what holds it", file, test.fn, want)
}
}
// The zero clause is looked for in the doc with its wrapping
// collapsed, because "Zero" falls at the end of a line in some of
// these comments and a re-flow moving it must not turn this red.
// What is compared is the ruled MEANING and not the token, so a
// clause stating the opposite fails rather than passing.
if test.zeroClause != "" && !strings.Contains(strings.Join(strings.Fields(doc), " "), test.zeroClause) {
t.Errorf("funcDoc(%q, %q) does not state %q, want that clause: with one configuration door a zero is the literal zero with this option's own meaning, and no declaration can say either", file, test.fn, test.zeroClause)
}
})
}
}
// funcDoc returns the doc comment of one top-level function in a file of this
// module.
func funcDoc(t *testing.T, file, name string) string {
t.Helper()
parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, parser.ParseComments|parser.SkipObjectResolution)
if err != nil {
t.Fatalf("Setup: parsing %s: %v", file, err)
}
for _, decl := range parsed.Decls {
fn, ok := decl.(*ast.FuncDecl)
if ok && fn.Recv == nil && fn.Name.Name == name {
return fn.Doc.Text()
}
}
t.Fatalf("Setup: %s declares no func %s", file, name)
return ""
}
// TestSurfaceExcludesInternal keeps the pin to what a consumer can import: a
// change under internal/ breaks nobody, so it must not show up as surface drift.
func TestSurfaceExcludesInternal(t *testing.T) {
got, err := surface.Generate(".")
if err != nil {
t.Fatalf("Setup: surface.Generate(%q): %v", ".", err)
}
if strings.Contains(string(got), "## package surface") {
t.Errorf("surface.Generate(%q) rendered the internal generator package, which is not part of the surface", ".")
}
}
// firstDifference reports the 1-based line number where two renderings first
// disagree, with both lines.
func firstDifference(got, want string) (int, string, string) {
gotLines := strings.Split(got, "\n")
wantLines := strings.Split(want, "\n")
for i := range max(len(gotLines), len(wantLines)) {
g, w := at(gotLines, i), at(wantLines, i)
if g != w {
return i + 1, g, w
}
}
return 0, "", ""
}
func at(lines []string, i int) string {
if i >= len(lines) {
return "<end of file>"
}
return lines[i]
}