lex_test.go
1package lex
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "slices"
9 "sort"
10 "strings"
11 "testing"
12)
13
14// lexObject is the part of a lexicon object definition the test compares.
15type lexObject struct {
16 Required []string `json:"required"`
17 Properties map[string]json.RawMessage `json:"properties"`
18}
19
20// loadDef returns the object definition at ref ("<nsid>#<def>", where def may
21// be "main", "main.input" or "main.output" for procedures).
22func loadDef(t *testing.T, ref string) lexObject {
23 t.Helper()
24 nsid, def, _ := strings.Cut(ref, "#")
25 var path string
26 for _, dir := range []string{"tangled", "ci"} {
27 p := filepath.Join("..", "..", "lexicons", dir, nsid+".json")
28 if _, err := os.Stat(p); err == nil {
29 path = p
30 }
31 }
32 if path == "" {
33 t.Fatalf("%s: no lexicon file", ref)
34 }
35 b, err := os.ReadFile(path)
36 if err != nil {
37 t.Fatal(err)
38 }
39 var doc struct {
40 Defs map[string]struct {
41 lexObject
42 Record *lexObject `json:"record"`
43 Input *struct {
44 Schema lexObject `json:"schema"`
45 } `json:"input"`
46 Output *struct {
47 Schema lexObject `json:"schema"`
48 } `json:"output"`
49 } `json:"defs"`
50 }
51 if err := json.Unmarshal(b, &doc); err != nil {
52 t.Fatalf("%s: %v", path, err)
53 }
54 name, part, _ := strings.Cut(def, ".")
55 d, ok := doc.Defs[name]
56 if !ok {
57 t.Fatalf("%s: no def %q", path, name)
58 }
59 switch {
60 case part == "input":
61 return d.Input.Schema
62 case part == "output":
63 return d.Output.Schema
64 case d.Record != nil:
65 return *d.Record
66 }
67 return d.lexObject
68}
69
70// jsonFields returns a struct's JSON field names (without $type) and which of
71// them are omitempty.
72func jsonFields(typ reflect.Type) (names []string, omit map[string]bool) {
73 omit = map[string]bool{}
74 for f := range typ.Fields() {
75 tag := f.Tag.Get("json")
76 name, opts, _ := strings.Cut(tag, ",")
77 if name == "" || name == "-" || name == "$type" {
78 continue
79 }
80 names = append(names, name)
81 omit[name] = strings.Contains(opts, "omitempty")
82 }
83 sort.Strings(names)
84 return names, omit
85}
86
87func TestTypesMatchLexicons(t *testing.T) {
88 cases := []struct {
89 ref string
90 v any
91 }{
92 {"sh.tangled.git.refUpdate#main", GitRefUpdate{}},
93 {"sh.tangled.git.refUpdate#meta", GitRefUpdateMeta{}},
94 {"sh.tangled.git.refUpdate#langBreakdown", GitRefUpdateLangBreakdown{}},
95 {"sh.tangled.git.refUpdate#individualLanguageSize", GitRefUpdateLanguageSize{}},
96 {"sh.tangled.git.refUpdate#commitCountBreakdown", GitRefUpdateCommitCount{}},
97 {"sh.tangled.git.refUpdate#individualEmailCommitCount", GitRefUpdateEmailCommitCount{}},
98 {"sh.tangled.pipeline#main", Pipeline{}},
99 {"sh.tangled.pipeline#triggerMetadata", PipelineTriggerMetadata{}},
100 {"sh.tangled.pipeline#triggerRepo", PipelineTriggerRepo{}},
101 {"sh.tangled.pipeline#pushTriggerData", PipelinePushTriggerData{}},
102 {"sh.tangled.pipeline#pullRequestTriggerData", PipelinePullRequestTrigger{}},
103 {"sh.tangled.pipeline#manualTriggerData", PipelineManualTriggerData{}},
104 {"sh.tangled.pipeline#scheduleTriggerData", PipelineScheduleTrigger{}},
105 {"sh.tangled.pipeline#workflow", PipelineWorkflow{}},
106 {"sh.tangled.pipeline#cloneOpts", PipelineCloneOpts{}},
107 {"sh.tangled.pipeline#pair", PipelinePair{}},
108 {"sh.tangled.pipeline.status#main", PipelineStatus{}},
109 {"sh.tangled.ci.triggerPipeline#main.input", TriggerPipelineInput{}},
110 {"sh.tangled.ci.triggerPipeline#main.output", TriggerPipelineOutput{}},
111 {"sh.tangled.ci.trigger#manual", ManualTrigger{}},
112 {"run.rtw.ci.workflow#main", Workflow{}},
113 {"run.rtw.ci.workflow#trigger", Trigger{}},
114 {"run.rtw.ci.workflow#repo", Repo{}},
115 {"run.rtw.ci.workflow#action", Action{}},
116 {"run.rtw.ci.workflow#gate", Gate{}},
117 {"run.rtw.ci.workflow#concurrency", Concurrency{}},
118 {"run.rtw.ci.step.result#main", Result{}},
119 {"run.rtw.ci.step.result#option", Option{}},
120 {"run.rtw.ci.step.result#attachment", Attachment{}},
121 {"run.rtw.ci.gate#main", GateRecord{}},
122 {"run.rtw.ci.transition#main", Transition{}},
123 {"run.rtw.ci.event#main", RunEvent{}},
124 }
125 for _, c := range cases {
126 t.Run(c.ref, func(t *testing.T) {
127 def := loadDef(t, c.ref)
128 want := make([]string, 0, len(def.Properties))
129 for k := range def.Properties {
130 want = append(want, k)
131 }
132 sort.Strings(want)
133 got, omit := jsonFields(reflect.TypeOf(c.v))
134 if !slices.Equal(got, want) {
135 t.Errorf("fields differ\n go: %v\n lexicon: %v", got, want)
136 }
137 for _, r := range def.Required {
138 if omit[r] {
139 t.Errorf("required property %q is omitempty in Go", r)
140 }
141 }
142 })
143 }
144}
145
146func TestNextForms(t *testing.T) {
147 cases := []struct {
148 in, out string
149 stop bool
150 n int
151 }{
152 {`"stop"`, `"stop"`, true, 0},
153 {`"rollback"`, `[{"action":"rollback"}]`, false, 1},
154 {`{"action":"rollback","then":"stop"}`, `[{"action":"rollback","then":"stop"}]`, false, 1},
155 {`[{"action":"a"},{"action":"b","label":"B"}]`, `[{"action":"a"},{"action":"b","label":"B"}]`, false, 2},
156 }
157 for _, c := range cases {
158 var n Next
159 if err := json.Unmarshal([]byte(c.in), &n); err != nil {
160 t.Fatalf("%s: %v", c.in, err)
161 }
162 if n.Stop != c.stop || len(n.Options) != c.n {
163 t.Errorf("%s: got %+v", c.in, n)
164 }
165 b, _ := json.Marshal(n)
166 if string(b) != c.out {
167 t.Errorf("%s: marshals to %s, want %s", c.in, b, c.out)
168 }
169 }
170 for _, bad := range []string{`""`, `[]`, `5`, `{"action":"a","oops":1}`, `[{"actoin":"a"}]`} {
171 var n Next
172 if err := json.Unmarshal([]byte(bad), &n); err == nil {
173 t.Errorf("%s: accepted", bad)
174 }
175 }
176}
177
178// The example result from DESIGN.md ยง7 round-trips.
179func TestDesignResultExample(t *testing.T) {
180 in := `{
181 "version": 1,
182 "status": "failure",
183 "summary": "mx99.eu.sovrn.at /healthz 503 for 4m (stalwart: connection refused)",
184 "next": [
185 { "action": "rollback", "prompt": "mx99 degraded after deploy. Roll back sovrn?", "then": "stop" },
186 { "action": "health-check", "label": "Check again" }
187 ],
188 "gated": true,
189 "attachments": [ { "label": "health probes", "path": "health.ndjson", "type": "ndjson" } ],
190 "outputs": { "hosts": ["mx99.eu.sovrn.at"] }
191}`
192 var r Result
193 if err := StrictUnmarshal([]byte(in), &r); err != nil {
194 t.Fatal(err)
195 }
196 if r.Status != Failure || !r.Gated || len(r.Next.Options) != 2 || r.Next.Options[0].Then != ThenStop {
197 t.Fatalf("decoded %+v", r)
198 }
199 b, err := json.Marshal(r)
200 if err != nil {
201 t.Fatal(err)
202 }
203 var again Result
204 if err := StrictUnmarshal(b, &again); err != nil {
205 t.Fatalf("re-decode %s: %v", b, err)
206 }
207 if !reflect.DeepEqual(normalise(t, r), normalise(t, again)) {
208 t.Errorf("round trip changed the result:\n%s", b)
209 }
210}
211
212func normalise(t *testing.T, r Result) map[string]any {
213 b, _ := json.Marshal(r)
214 var m map[string]any
215 if err := json.Unmarshal(b, &m); err != nil {
216 t.Fatal(err)
217 }
218 return m
219}