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:
Jakub Zych
2026-09-17 12:20:22 +02:00
parent acd31ba28c
commit f6e1b892bc
12 changed files with 1143 additions and 0 deletions

220
tide/diff.go Normal file
View 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()
}