From 591a0d46817ace922bd55af6ef9ca5e1e5b312fa Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 17 Sep 2026 12:51:24 +0200 Subject: [PATCH] fix(02-03): expand expected placeholders after replay capture Unquoted numeric id placeholders made recorded JSON illegal to parse. Replay now recaptures, persists vars, expands the expected body, and diffs against the live response. Co-authored-by: Cursor --- tide/capture_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++++ tide/replay.go | 13 +++++++++++- tide/variables.go | 20 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/tide/capture_test.go b/tide/capture_test.go index 2fc761e..29ca171 100644 --- a/tide/capture_test.go +++ b/tide/capture_test.go @@ -272,6 +272,53 @@ func post(t *testing.T, url, session, ct, body string) { } } +func TestReplayExpandsUnquotedIDPlaceholdersAfterCapture(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/login": + _, _ = w.Write([]byte(`{"token":"` + testJWT + `","user":{"id":1}}`)) + case "/genres": + _, _ = w.Write([]byte(`{"data":[{"id":1,"name":"Rock","album_count":0}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + flow := Flow{ + Version: 1, + Name: "seed-then-list", + Steps: []Step{ + { + ID: "login", + Request: Request{Method: http.MethodPost, Path: "/login"}, + Response: Response{ + Status: 200, + Headers: jsonCT(), + Body: Body(`{"token":"{{jwt:alice}}","user":{"id":{{id:alice}}}}`), + }, + Capture: []CaptureRule{ + {From: "response.json", Path: "$.token", As: "jwt:alice", Category: "jwt"}, + {From: "response.json", Path: "$.user.id", As: "id:alice"}, + }, + }, + { + ID: "genres", + Request: Request{Method: http.MethodGet, Path: "/genres"}, + Response: Response{ + Status: 200, + Headers: jsonCT(), + Body: Body(`{"data":[{"id":{{id:alice}},"name":"Rock","album_count":0}]}`), + }, + }, + }, + } + if _, err := ReplayFlow(context.Background(), flow, ReplayConfig{Target: srv.URL}); err != nil { + t.Fatalf("replay with unquoted id placeholders: %v", err) + } +} + func TestScrubShortNumericIDsDoNotCorruptPaths(t *testing.T) { store, err := OpenStore("") if err != nil { diff --git a/tide/replay.go b/tide/replay.go index 5ef8524..65238ec 100644 --- a/tide/replay.go +++ b/tide/replay.go @@ -59,7 +59,18 @@ func ReplayFlow(ctx context.Context, flow Flow, cfg ReplayConfig) (Result, error skipRest = true continue } - _ = ScrubStep(store, &live) + if err := store.Save(); err != nil { + return result, fmt.Errorf("tide: replay step %s: %w", step.ID, err) + } + want.Response, err = expandResponse(want.Response, store) + if err != nil { + sr.OK = false + result.OK = false + sr.Diffs = append(sr.Diffs, Diff{Path: "expected", Expected: "resolved placeholders", Actual: err.Error()}) + result.Steps = append(result.Steps, sr) + skipRest = true + continue + } sr.Diffs = append(sr.Diffs, compareStep(want, live.Response)...) if len(sr.Diffs) > 0 { sr.OK = false diff --git a/tide/variables.go b/tide/variables.go index 4ad93fa..1084f39 100644 --- a/tide/variables.go +++ b/tide/variables.go @@ -199,6 +199,26 @@ func expandRequest(req Request, store *Store) (Request, error) { return out, nil } +func expandResponse(resp Response, store *Store) (Response, error) { + out := resp + var err error + if resp.Headers != nil { + out.Headers = make(map[string]string, len(resp.Headers)) + for k, v := range resp.Headers { + out.Headers[k], err = store.Expand(v) + if err != nil { + return Response{}, err + } + } + } + body, err := store.Expand(string(resp.Body)) + if err != nil { + return Response{}, err + } + out.Body = Body(body) + return out, nil +} + // CaptureStep writes named values from the step into the store. func CaptureStep(store *Store, step *Step) error { if store == nil || step == nil || len(step.Capture) == 0 {