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 <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 12:51:24 +02:00
parent 1ea3c59055
commit 591a0d4681
3 changed files with 79 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {