feat(02-01): record and replay one route through the summer CLI
- Add a generic tide flow schema with YAML fixture IO and HTTP record/replay - Register parity:record and parity:replay on the bonfire summer tool - Diff JSON scalars at $.path and non-JSON bodies at the changed byte offset Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
220
tide/diff.go
Normal file
220
tide/diff.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package tide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func compareBodies(want, got Response) []Diff {
|
||||
wantJSON := isJSONContentType(want.Headers)
|
||||
gotJSON := isJSONContentType(got.Headers)
|
||||
if wantJSON && gotJSON {
|
||||
return diffJSON([]byte(want.Body), []byte(got.Body))
|
||||
}
|
||||
return diffBytes([]byte(want.Body), []byte(got.Body))
|
||||
}
|
||||
|
||||
func isJSONContentType(headers map[string]string) bool {
|
||||
ct := headerValue(headers, "Content-Type")
|
||||
if ct == "" {
|
||||
return false
|
||||
}
|
||||
media, _, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
return strings.Contains(strings.ToLower(ct), "json")
|
||||
}
|
||||
return media == "application/json" || strings.HasSuffix(media, "+json")
|
||||
}
|
||||
|
||||
func headerValue(headers map[string]string, name string) string {
|
||||
if v, ok := headers[name]; ok {
|
||||
return v
|
||||
}
|
||||
for k, v := range headers {
|
||||
if strings.EqualFold(k, name) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func diffJSON(want, got []byte) []Diff {
|
||||
wantVal, err := decodeJSON(want)
|
||||
if err != nil {
|
||||
return []Diff{{Path: "$", Expected: "valid JSON", Actual: err.Error()}}
|
||||
}
|
||||
gotVal, err := decodeJSON(got)
|
||||
if err != nil {
|
||||
return []Diff{{Path: "$", Expected: formatValue(wantVal), Actual: err.Error()}}
|
||||
}
|
||||
var diffs []Diff
|
||||
compareValue("$", wantVal, gotVal, &diffs)
|
||||
return diffs
|
||||
}
|
||||
|
||||
func decodeJSON(raw []byte) (any, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
if err := dec.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func compareValue(path string, want, got any, diffs *[]Diff) {
|
||||
switch w := want.(type) {
|
||||
case map[string]any:
|
||||
g, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
||||
return
|
||||
}
|
||||
for k, wv := range w {
|
||||
gv, exists := g[k]
|
||||
child := pathJoin(path, k)
|
||||
if !exists {
|
||||
*diffs = append(*diffs, Diff{Path: child, Expected: formatValue(wv), Actual: "<missing>"})
|
||||
continue
|
||||
}
|
||||
compareValue(child, wv, gv, diffs)
|
||||
}
|
||||
for k, gv := range g {
|
||||
if _, exists := w[k]; !exists {
|
||||
*diffs = append(*diffs, Diff{Path: pathJoin(path, k), Expected: "<missing>", Actual: formatValue(gv)})
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
g, ok := got.([]any)
|
||||
if !ok {
|
||||
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
||||
return
|
||||
}
|
||||
if len(w) != len(g) {
|
||||
*diffs = append(*diffs, Diff{
|
||||
Path: path,
|
||||
Expected: fmt.Sprintf("array[%d]", len(w)),
|
||||
Actual: fmt.Sprintf("array[%d]", len(g)),
|
||||
})
|
||||
}
|
||||
n := min(len(w), len(g))
|
||||
for i := 0; i < n; i++ {
|
||||
compareValue(fmt.Sprintf("%s[%d]", path, i), w[i], g[i], diffs)
|
||||
}
|
||||
default:
|
||||
if !scalarEqual(want, got) {
|
||||
*diffs = append(*diffs, Diff{Path: path, Expected: formatValue(want), Actual: formatValue(got)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scalarEqual(want, got any) bool {
|
||||
if want == nil || got == nil {
|
||||
return want == nil && got == nil
|
||||
}
|
||||
switch w := want.(type) {
|
||||
case json.Number:
|
||||
g, ok := got.(json.Number)
|
||||
return ok && w == g
|
||||
case string:
|
||||
g, ok := got.(string)
|
||||
return ok && w == g
|
||||
case bool:
|
||||
g, ok := got.(bool)
|
||||
return ok && w == g
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func formatValue(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case json.Number:
|
||||
return "number " + string(t)
|
||||
case string:
|
||||
return strconv.Quote(t)
|
||||
case bool:
|
||||
return fmt.Sprintf("%v", t)
|
||||
case map[string]any:
|
||||
return "object"
|
||||
case []any:
|
||||
return fmt.Sprintf("array[%d]", len(t))
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
func pathJoin(parent, key string) string {
|
||||
if parent == "$" {
|
||||
return "$." + key
|
||||
}
|
||||
return parent + "." + key
|
||||
}
|
||||
|
||||
func diffBytes(want, got []byte) []Diff {
|
||||
n := min(len(want), len(got))
|
||||
off := n
|
||||
for i := 0; i < n; i++ {
|
||||
if want[i] != got[i] {
|
||||
off = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if off == n && len(want) == len(got) {
|
||||
return nil
|
||||
}
|
||||
return []Diff{{
|
||||
Path: fmt.Sprintf("body[%d]", off),
|
||||
Expected: printableWindow(want, off),
|
||||
Actual: printableWindow(got, off),
|
||||
Offset: off,
|
||||
Byte: true,
|
||||
}}
|
||||
}
|
||||
|
||||
func printableWindow(b []byte, off int) string {
|
||||
if len(b) == 0 {
|
||||
return `""`
|
||||
}
|
||||
start := off - 8
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := off + 8
|
||||
if end > len(b) {
|
||||
end = len(b)
|
||||
}
|
||||
return quotePrintable(b[start:end])
|
||||
}
|
||||
|
||||
func quotePrintable(b []byte) string {
|
||||
var buf strings.Builder
|
||||
buf.WriteByte('"')
|
||||
for i := 0; i < len(b); {
|
||||
r, size := utf8.DecodeRune(b[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
fmt.Fprintf(&buf, "\\x%02x", b[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if r == '\\' || r == '"' {
|
||||
buf.WriteByte('\\')
|
||||
buf.WriteRune(r)
|
||||
} else if unicode.IsPrint(r) {
|
||||
buf.WriteRune(r)
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "\\u%04x", r)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
buf.WriteByte('"')
|
||||
return buf.String()
|
||||
}
|
||||
Reference in New Issue
Block a user