Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f1536de
fix(parser): join array params by their style, fill server variables
xseman Oct 26, 2026
73ceffc
fix(typescript-fetch): convert values at any depth, by style and by m…
xseman Oct 26, 2026
ebb871e
test(e2e): hold typescript-fetch to strict tsc and exact generated va…
xseman Oct 26, 2026
c8a6133
fix(typescript-fetch): type and convert schema aliases, allOf union m…
xseman Oct 26, 2026
abfcfc0
test(e2e): cover schema aliases and an unmapped discriminator value
xseman Oct 26, 2026
cb81dac
test(e2e): check days at local midnight and every query and header
xseman Oct 26, 2026
4b46126
fix(typescript-fetch): convert inline unions of a body, of lists, of any
xseman Oct 26, 2026
f1fa151
test(e2e): expose union bodies, empty headers and CRLF event streams
xseman Oct 26, 2026
0a451a2
fix(typescript-fetch): leave unset properties out of parameters, buil…
xseman Oct 26, 2026
b27cde5
test(e2e): compile every typescript-fetch option, leave properties unset
xseman Oct 26, 2026
6842d06
fix(typescript-fetch): tell union members by int and multi-value tags…
xseman Oct 26, 2026
c8eaef7
fix(go-fetch): read an any sent as an image or as octets as bytes
xseman Oct 26, 2026
c0294bc
test(e2e): cover int and multi-value union tags, binary any, +json bo…
xseman Oct 26, 2026
bfb3de8
fix(typescript-fetch): keep a union's tag, convert list alias members
xseman Oct 26, 2026
1ecf976
fix(go-fetch): tell a text media type by its subtype
xseman Oct 26, 2026
dd59196
test(e2e): cover union tags no member declares and list alias members
xseman Oct 26, 2026
470a922
fix(typescript-fetch): null only for the statuses declared without a …
xseman Oct 26, 2026
bf58db4
test(e2e): an empty body where the spec declares one fails
xseman Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 45 additions & 21 deletions internal/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,20 @@ import (
type runner struct {
generator string
run func(t *testing.T, suites []suite) map[string]map[int]outcome

// lenient counts its failing generated cases and skips them, until a
// later change fixes its known gaps.
lenient bool

// millis compares its date-times as instants to the millisecond: they
// are JavaScript Dates.
millis bool
}

var runners = []runner{
{"go-fetch", runGoFetch},
{"typescript-fetch", runTypeScript},
{"dart-fetch", runDart},
{generator: "go-fetch", run: runGoFetch},
{generator: "typescript-fetch", run: runTypeScript, millis: true},
{generator: "dart-fetch", run: runDart, lenient: true},
}

// forEachRunner runs fn per generator; E2E_GENERATOR=<name> picks one.
Expand Down Expand Up @@ -91,7 +99,7 @@ func TestMock(t *testing.T) {

for _, s := range suites {
t.Run(s.Name, func(t *testing.T) {
tally[s.Name] = checkCases(t, r.generator, byName[s.Name], outcomes[s.Name], mocks[s.Name])
tally[s.Name] = checkCases(t, r, byName[s.Name], outcomes[s.Name], mocks[s.Name])
})
}

Expand Down Expand Up @@ -160,7 +168,7 @@ func TestFastAPI(t *testing.T) {

var c *counts

t.Run(s.Name, func(t *testing.T) { c = checkCases(t, r.generator, s, outcomes[s.Name], nil) })
t.Run(s.Name, func(t *testing.T) { c = checkCases(t, r, s, outcomes[s.Name], nil) })
report(t, r.generator, map[string]*counts{s.Name: c})
}
})
Expand All @@ -174,7 +182,7 @@ func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suit
t.Fatal(err)
}

params := opParams(doc)
params, bodies := opParams(doc), opBodies(doc)

out := suite{Name: s.Name, Base: base, Mock: isMock, Auth: s.Auth, Spec: specPath, Schemes: map[string]string{}}

Expand All @@ -195,7 +203,7 @@ func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suit
t.Fatalf("%s %s: request and response are for the mock; a backend answers itself", s.Name, k.Op)
}

out.Cases = append(out.Cases, suiteCase{Op: k.Op, Params: params[k.Op], Args: k.Args, Auth: k.Auth, Save: k.Save})
out.Cases = append(out.Cases, suiteCase{Op: k.Op, Params: params[k.Op], Bodies: bodies[k.Op], Args: k.Args, Auth: k.Auth, Save: k.Save})
}

return out
Expand All @@ -204,7 +212,7 @@ func toSuite(t *testing.T, s *scenario, specPath, base string, isMock bool) suit
// checkCases compares each case's outcome, and with a mock the request it
// received, with the scenario. A case skipped for this generator must still
// fail: one that passes has its skip left over.
func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]outcome, m *mock) *counts {
func checkCases(t *testing.T, r runner, s *scenario, outcomes map[int]outcome, m *mock) *counts {
t.Helper()

c := &counts{}
Expand All @@ -219,14 +227,14 @@ func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]ou
var diffs []string

if o, ok := outcomes[i]; ok {
diffs = caseDiffs(k, o, m, i)
diffs = caseDiffs(k, o, m, i, rules{exact: k.Exact, millis: r.millis})
} else {
diffs = []string{"the runner reported nothing for this case"}
}

reason, skipped := k.Skip[generator]
reason, skipped := k.Skip[r.generator]
if !skipped {
reason, skipped = s.Skip[generator]
reason, skipped = s.Skip[r.generator]
}

switch {
Expand All @@ -240,7 +248,7 @@ func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]ou

t.Skipf("%s (%s)", reason, strings.Join(diffs, "; "))

case len(diffs) > 0 && k.Exact && valueLenient[generator]:
case len(diffs) > 0 && k.Exact && r.lenient:
c.fail++

t.Skip("lenient until its gaps are fixed:\n" + strings.Join(diffs, "\n"))
Expand All @@ -259,7 +267,7 @@ func checkCases(t *testing.T, generator string, s *scenario, outcomes map[int]ou
return c
}

func caseDiffs(k kase, o outcome, m *mock, i int) []string {
func caseDiffs(k kase, o outcome, m *mock, i int, r rules) []string {
var diffs []string

// With a mock every case's request must be valid for the spec, and the
Expand All @@ -277,20 +285,36 @@ func caseDiffs(k kase, o outcome, m *mock, i int) []string {
case got["responseInvalid"] != nil:
diffs = append(diffs, fmt.Sprintf("response: the scenario's is invalid for the spec: %v", got["responseInvalid"]))
case k.Request != nil && k.Exact:
// The body alone, absent when none was sent: null is not absent.
sent := map[string]any{}
if b, ok := got["body"]; ok {
sent["body"] = b
// The query and the headers as the scenarios read them (another
// key, an API key, may be there), the body exactly: absent when
// none was sent, and null is not absent.
want, _ := decodeJSON(k.Request).(map[string]any)
for _, where := range []string{"query", "header"} {
if w, ok := want[where]; ok {
diffs = append(diffs, match("request."+where, w, got[where], rules{millis: r.millis})...)
}
}

diffs = append(diffs, match("request", decodeJSON(k.Request), sent, true)...)
if b, ok := want["body"]; ok {
sent := map[string]any{}
if gb, ok := got["body"]; ok {
sent["body"] = gb
}

diffs = append(diffs, match("request", map[string]any{"body": b}, sent, r)...)
}

case k.Request != nil:
diffs = append(diffs, match("request", decodeJSON(k.Request), got, false)...)
diffs = append(diffs, match("request", decodeJSON(k.Request), got, r)...)
}
}

switch {
case k.Fails:
if o.Fail == "" {
diffs = append(diffs, "call: want it to fail, got result "+string(o.Result))
}

case o.Fail != "":
diffs = append(diffs, "call failed: "+o.Fail)
case k.Error != nil:
Expand All @@ -305,12 +329,12 @@ func caseDiffs(k kase, o outcome, m *mock, i int) []string {
got["model"] = decodeJSON(o.Error.Model)
}

diffs = append(diffs, match("error", decodeJSON(k.Error), got, k.Exact)...)
diffs = append(diffs, match("error", decodeJSON(k.Error), got, r)...)

case o.Error != nil:
diffs = append(diffs, fmt.Sprintf("error: %d %s", o.Error.Status, o.Error.Body))
case k.Result != nil:
diffs = append(diffs, match("result", decodeJSON(k.Result), decodeJSON(o.Result), k.Exact)...)
diffs = append(diffs, match("result", decodeJSON(k.Result), decodeJSON(o.Result), r)...)
}

return diffs
Expand Down
1 change: 1 addition & 0 deletions internal/e2e/gofetch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type suite struct {
type suiteCase struct {
Op string `json:"op"`
Params []string `json:"params"`
Bodies []string `json:"bodies,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
Auth *auth `json:"auth,omitempty"`
Save map[string]string `json:"save,omitempty"`
Expand Down
56 changes: 47 additions & 9 deletions internal/e2e/match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,26 @@ import (
"fmt"
"maps"
"math/big"
"mime"
"slices"
"strings"
"testing"
"time"
)

// rules are how strictly match compares. Exact, for the generated cases,
// allows no other keys and no string for a list. Millis is for a client
// whose date-times are JavaScript Dates: they keep the instant to the
// millisecond, not the offset or the digits past it.
type rules struct{ exact, millis bool }

// match compares a scenario's expectation with what arrived, the way the
// scenarios read: an object lists the keys it checks (others may be there),
// an array has exactly its items, "$any" is any value, "$absent" is a key
// that must be missing. In a request, where a query, header or form value is
// a list, a string stands for a list of just that string, and header names
// match in any case. Exact, for the generated cases, allows no other keys
// and no string for a list. It returns one line per difference.
func match(path string, want, got any, exact bool) []string {
// match in any case. It returns one line per difference.
func match(path string, want, got any, r rules) []string {
switch w := want.(type) {
case string:
switch w {
Expand All @@ -27,11 +34,16 @@ func match(path string, want, got any, exact bool) []string {
return []string{path + ": want absent, got " + show(got)}
}

if g, ok := got.([]any); ok && len(g) == 1 && !exact && strings.HasPrefix(path, "request.") {
if g, ok := got.([]any); ok && len(g) == 1 && !r.exact && strings.HasPrefix(path, "request.") {
got = g[0]
}

if g, ok := got.(string); !ok || g != w {
// A Content-Type's parameters (a charset fetch adds) do not count.
if g, ok := got.(string); ok && strings.EqualFold(path, "request.header.Content-Type") {
got, _, _ = mime.ParseMediaType(g)
}

if g, ok := got.(string); !ok || g != w && (!r.millis || !sameMillis(w, g)) {
return []string{path + ": want " + show(w) + ", got " + show(got)}
}

Expand Down Expand Up @@ -60,12 +72,12 @@ func match(path string, want, got any, exact bool) []string {
case !present:
diffs = append(diffs, path+"."+k+": missing, want "+show(w[k]))
default:
diffs = append(diffs, match(path+"."+k, w[k], gv, exact)...)
diffs = append(diffs, match(path+"."+k, w[k], gv, r)...)
}
}

for _, k := range slices.Sorted(maps.Keys(g)) {
if _, wanted := w[k]; exact && !wanted {
if _, wanted := w[k]; r.exact && !wanted {
diffs = append(diffs, path+"."+k+": want absent, got "+show(g[k]))
}
}
Expand All @@ -80,7 +92,7 @@ func match(path string, want, got any, exact bool) []string {

var diffs []string
for i := range w {
diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], exact)...)
diffs = append(diffs, match(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], r)...)
}

return diffs
Expand Down Expand Up @@ -121,6 +133,16 @@ func sameNumber(a, b json.Number) bool {
return okA && okB && x.Cmp(y) == 0
}

// sameMillis says whether two RFC 3339 date-times are one instant to the
// millisecond: 03:04:05Z and 03:04:05.000Z, 23:59:59.123456+05:30 and
// 18:29:59.123Z.
func sameMillis(a, b string) bool {
x, errA := time.Parse(time.RFC3339Nano, a)
y, errB := time.Parse(time.RFC3339Nano, b)

return errA == nil && errB == nil && x.Truncate(time.Millisecond).Equal(y.Truncate(time.Millisecond))
}

func show(v any) string {
b, err := json.Marshal(v)
if err != nil {
Expand All @@ -140,6 +162,7 @@ func TestMatch(t *testing.T) {
{"missing key", "result", `{"a":1}`, `{"b":1}`, false, false},
{"key case matters in a result", "result", `{"id":"1"}`, `{"ID":"1"}`, false, false},
{"header name in any case", "request.header", `{"X-Request-ID":"r"}`, `{"X-Request-Id":["r"]}`, false, true},
{"a Content-Type by its media type", "request.header", `{"Content-Type":"a/b"}`, `{"Content-Type":["a/b;charset=UTF-8"]}`, false, true},
{"string for a one-value list in a request", "request.query", `{"q":"a"}`, `{"q":["a"]}`, false, true},
{"but not in a result", "result", `"stored"`, `["stored"]`, false, false},
{"array exact length", "result", `[1]`, `[1,2]`, false, false},
Expand All @@ -160,12 +183,27 @@ func TestMatch(t *testing.T) {
{"exact: absent is not null", "result", `{}`, `{"a":null}`, true, false},
{"exact: no string for a list", "request.body", `{"q":"a"}`, `{"q":["a"]}`, true, false},
{"exact: $any still any", "error", `{"status":500,"body":"$any"}`, `{"status":500,"body":"x"}`, true, true},
{"date-time digits matter", "result", `"2024-01-02T03:04:05Z"`, `"2024-01-02T03:04:05.000Z"`, true, false},
}

for _, tt := range tests {
diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got)), tt.exact)
diffs := match(tt.path, decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got)), rules{exact: tt.exact})
if (len(diffs) == 0) != tt.ok {
t.Errorf("%s: match(%s, %s, exact %t) = %v, want ok %t", tt.name, tt.want, tt.got, tt.exact, diffs, tt.ok)
}
}

millis := rules{exact: true, millis: true}
for _, tt := range []struct {
want, got string
ok bool
}{
{`"2024-02-29T23:59:59.123456+05:30"`, `"2024-02-29T18:29:59.123Z"`, true},
{`"2024-01-02T03:04:05Z"`, `"2024-01-02T03:04:06.000Z"`, false},
{`"2024-01-02T03:04:05"`, `"2024-01-02T02:04:05.000Z"`, false}, // naive: text
} {
if diffs := match("result", decodeJSON([]byte(tt.want)), decodeJSON([]byte(tt.got)), millis); (len(diffs) == 0) != tt.ok {
t.Errorf("millis: match(%s, %s) = %v, want ok %t", tt.want, tt.got, diffs, tt.ok)
}
}
}
25 changes: 25 additions & 0 deletions internal/e2e/scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type kase struct {
Response *mockResponse `json:"response"`
Result json.RawMessage `json:"result"` // null is a null result, absent is unchecked
Error json.RawMessage `json:"error"`
Fails bool `json:"fails"` // the client cannot read the answer: the call fails, not as an HTTP error

// Exact compares the result, the error and the request's body with no
// other keys allowed: a generated case (values_test.go) sets it.
Expand Down Expand Up @@ -157,6 +158,30 @@ func opParams(doc *openapi3.T) map[string][]string {
return out
}

// opBodies lists the responses of each operation that have a body, by
// their key in the spec: "200", "404", "4XX", "default".
func opBodies(doc *openapi3.T) map[string][]string {
out := map[string][]string{}

for _, item := range doc.Paths.Map() {
for _, op := range item.Operations() {
if op.Responses == nil {
continue
}

for code, resp := range op.Responses.Map() {
if resp.Value != nil && len(resp.Value.Content) > 0 {
out[op.OperationID] = append(out[op.OperationID], code)
}
}

slices.Sort(out[op.OperationID])
}
}

return out
}

// decodeJSON decodes JSON keeping numbers exact.
func decodeJSON(b []byte) any {
var v any
Expand Down
Loading
Loading