on main help sign in
test: migrate tests to `charm.land/x/vcr`
13f7c0eAndrey Nering
go.mod+3−2
@@ -7,6 +7,7 @@ require (
7 charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251106195925-579e174cd7fa
7 charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251106195925-579e174cd7fa
8 charm.land/fantasy v0.2.1
8 charm.land/fantasy v0.2.1
9 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
9 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410
10+ charm.land/x/vcr v0.1.0
10 github.com/JohannesKaufmann/html-to-markdown v1.6.0
11 github.com/JohannesKaufmann/html-to-markdown v1.6.0
11 github.com/MakeNowJust/heredoc v1.0.0
12 github.com/MakeNowJust/heredoc v1.0.0
12 github.com/PuerkitoBio/goquery v1.10.3
13 github.com/PuerkitoBio/goquery v1.10.3
@@ -52,10 +53,8 @@ require (
52 github.com/stretchr/testify v1.11.1
53 github.com/stretchr/testify v1.11.1
53 github.com/tidwall/sjson v1.2.5
54 github.com/tidwall/sjson v1.2.5
54 github.com/zeebo/xxh3 v1.0.2
55 github.com/zeebo/xxh3 v1.0.2
55− go.yaml.in/yaml/v4 v4.0.0-rc.3
56 golang.org/x/sync v0.18.0
56 golang.org/x/sync v0.18.0
57 golang.org/x/text v0.30.0
57 golang.org/x/text v0.30.0
58− gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117
59 gopkg.in/natefinch/lumberjack.v2 v2.2.1
58 gopkg.in/natefinch/lumberjack.v2 v2.2.1
60 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
59 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
61 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
60 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
@@ -152,6 +151,7 @@ require (
152 go.opentelemetry.io/otel/metric v1.37.0 // indirect
151 go.opentelemetry.io/otel/metric v1.37.0 // indirect
153 go.opentelemetry.io/otel/trace v1.37.0 // indirect
152 go.opentelemetry.io/otel/trace v1.37.0 // indirect
154 go.uber.org/multierr v1.11.0 // indirect
153 go.uber.org/multierr v1.11.0 // indirect
154+ go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
155 golang.org/x/crypto v0.43.0 // indirect
155 golang.org/x/crypto v0.43.0 // indirect
156 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
156 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
157 golang.org/x/image v0.27.0 // indirect
157 golang.org/x/image v0.27.0 // indirect
@@ -165,6 +165,7 @@ require (
165 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
165 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
166 google.golang.org/grpc v1.74.2 // indirect
166 google.golang.org/grpc v1.74.2 // indirect
167 google.golang.org/protobuf v1.36.10 // indirect
167 google.golang.org/protobuf v1.36.10 // indirect
168+ gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
168 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
169 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
169 gopkg.in/yaml.v3 v3.0.1 // indirect
170 gopkg.in/yaml.v3 v3.0.1 // indirect
170)
171)
go.sum+4−2
@@ -6,6 +6,8 @@ charm.land/fantasy v0.2.1 h1:hi+azqT05hEdt0nLljYO4vyIASTAtmhcfhUdM3yFclI=
6charm.land/fantasy v0.2.1/go.mod h1:QyB3iv9GHs/6O90Z6ZjSTdM1XS3N/70Ujke+w3w0jLo=
6charm.land/fantasy v0.2.1/go.mod h1:QyB3iv9GHs/6O90Z6ZjSTdM1XS3N/70Ujke+w3w0jLo=
7charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
7charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
8charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
8charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
9+charm.land/x/vcr v0.1.0 h1:XhCUVij6Ss6+xJuAb2n4mNRGSS/SrnNoUmEwJziy+Dg=
10+charm.land/x/vcr v0.1.0/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
9cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
11cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
10cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
12cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
11cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
13cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
@@ -450,8 +452,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
450gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
452gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
451gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
453gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
452gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
454gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
453−gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117 h1:fbE/sTnBb9UNfE8cJsOzrYYPqVWVHb7jWH4SI1W//cM=
455+gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 h1:g3ah7zaWmw41EtOgBNXpx8zk4HYuH3OMwB+qh1Dt834=
454−gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20250923044825-7b4892dd3117/go.mod h1:YuVT9NPq7t3oT2WpUemB0DbNL7djIjgajZycxoDLnqs=
456+gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
455gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
457gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
456gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
458gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
457gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
459gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
internal/agent/agent_test.go+3−3
@@ -8,11 +8,11 @@ import (
8 "testing"
8 "testing"
9
9
10 "charm.land/fantasy"
10 "charm.land/fantasy"
11+ "charm.land/x/vcr"
11 "github.com/charmbracelet/crush/internal/agent/tools"
12 "github.com/charmbracelet/crush/internal/agent/tools"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/message"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 "github.com/stretchr/testify/require"
15− "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
16
16
17 _ "github.com/joho/godotenv/autoload"
17 _ "github.com/joho/godotenv/autoload"
18)
18)
@@ -24,7 +24,7 @@ var modelPairs = []modelPair{
24 {"zai-glm4.6", zAIBuilder("glm-4.6"), zAIBuilder("glm-4.5-air")},
24 {"zai-glm4.6", zAIBuilder("glm-4.6"), zAIBuilder("glm-4.5-air")},
25}
25}
26
26
27−func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) {
27+func getModels(t *testing.T, r *vcr.Recorder, pair modelPair) (fantasy.LanguageModel, fantasy.LanguageModel) {
28 large, err := pair.largeModel(t, r)
28 large, err := pair.largeModel(t, r)
29 require.NoError(t, err)
29 require.NoError(t, err)
30 small, err := pair.smallModel(t, r)
30 small, err := pair.smallModel(t, r)
@@ -33,7 +33,7 @@ func getModels(t *testing.T, r *recorder.Recorder, pair modelPair) (fantasy.Lang
33}
33}
34
34
35func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) {
35func setupAgent(t *testing.T, pair modelPair) (SessionAgent, fakeEnv) {
36− r := newRecorder(t)
36+ r := vcr.NewRecorder(t)
37 large, small := getModels(t, r, pair)
37 large, small := getModels(t, r, pair)
38 env := testEnv(t)
38 env := testEnv(t)
39
39
internal/agent/common_test.go+7−7
@@ -13,6 +13,7 @@ import (
13 "charm.land/fantasy/providers/openai"
13 "charm.land/fantasy/providers/openai"
14 "charm.land/fantasy/providers/openaicompat"
14 "charm.land/fantasy/providers/openaicompat"
15 "charm.land/fantasy/providers/openrouter"
15 "charm.land/fantasy/providers/openrouter"
16+ "charm.land/x/vcr"
16 "github.com/charmbracelet/catwalk/pkg/catwalk"
17 "github.com/charmbracelet/catwalk/pkg/catwalk"
17 "github.com/charmbracelet/crush/internal/agent/prompt"
18 "github.com/charmbracelet/crush/internal/agent/prompt"
18 "github.com/charmbracelet/crush/internal/agent/tools"
19 "github.com/charmbracelet/crush/internal/agent/tools"
@@ -25,7 +26,6 @@ import (
25 "github.com/charmbracelet/crush/internal/permission"
26 "github.com/charmbracelet/crush/internal/permission"
26 "github.com/charmbracelet/crush/internal/session"
27 "github.com/charmbracelet/crush/internal/session"
27 "github.com/stretchr/testify/require"
28 "github.com/stretchr/testify/require"
28− "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
29
29
30 _ "github.com/joho/godotenv/autoload"
30 _ "github.com/joho/godotenv/autoload"
31)
31)
@@ -40,7 +40,7 @@ type fakeEnv struct {
40 lspClients *csync.Map[string, *lsp.Client]
40 lspClients *csync.Map[string, *lsp.Client]
41}
41}
42
42
43−type builderFunc func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error)
43+type builderFunc func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error)
44
44
45type modelPair struct {
45type modelPair struct {
46 name string
46 name string
@@ -49,7 +49,7 @@ type modelPair struct {
49}
49}
50
50
51func anthropicBuilder(model string) builderFunc {
51func anthropicBuilder(model string) builderFunc {
52− return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
52+ return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
53 provider, err := anthropic.New(
53 provider, err := anthropic.New(
54 anthropic.WithAPIKey(os.Getenv("CRUSH_ANTHROPIC_API_KEY")),
54 anthropic.WithAPIKey(os.Getenv("CRUSH_ANTHROPIC_API_KEY")),
55 anthropic.WithHTTPClient(&http.Client{Transport: r}),
55 anthropic.WithHTTPClient(&http.Client{Transport: r}),
@@ -62,7 +62,7 @@ func anthropicBuilder(model string) builderFunc {
62}
62}
63
63
64func openaiBuilder(model string) builderFunc {
64func openaiBuilder(model string) builderFunc {
65− return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
65+ return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
66 provider, err := openai.New(
66 provider, err := openai.New(
67 openai.WithAPIKey(os.Getenv("CRUSH_OPENAI_API_KEY")),
67 openai.WithAPIKey(os.Getenv("CRUSH_OPENAI_API_KEY")),
68 openai.WithHTTPClient(&http.Client{Transport: r}),
68 openai.WithHTTPClient(&http.Client{Transport: r}),
@@ -75,7 +75,7 @@ func openaiBuilder(model string) builderFunc {
75}
75}
76
76
77func openRouterBuilder(model string) builderFunc {
77func openRouterBuilder(model string) builderFunc {
78− return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
78+ return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
79 provider, err := openrouter.New(
79 provider, err := openrouter.New(
80 openrouter.WithAPIKey(os.Getenv("CRUSH_OPENROUTER_API_KEY")),
80 openrouter.WithAPIKey(os.Getenv("CRUSH_OPENROUTER_API_KEY")),
81 openrouter.WithHTTPClient(&http.Client{Transport: r}),
81 openrouter.WithHTTPClient(&http.Client{Transport: r}),
@@ -88,7 +88,7 @@ func openRouterBuilder(model string) builderFunc {
88}
88}
89
89
90func zAIBuilder(model string) builderFunc {
90func zAIBuilder(model string) builderFunc {
91− return func(t *testing.T, r *recorder.Recorder) (fantasy.LanguageModel, error) {
91+ return func(t *testing.T, r *vcr.Recorder) (fantasy.LanguageModel, error) {
92 provider, err := openaicompat.New(
92 provider, err := openaicompat.New(
93 openaicompat.WithBaseURL("https://api.z.ai/api/coding/paas/v4"),
93 openaicompat.WithBaseURL("https://api.z.ai/api/coding/paas/v4"),
94 openaicompat.WithAPIKey(os.Getenv("CRUSH_ZAI_API_KEY")),
94 openaicompat.WithAPIKey(os.Getenv("CRUSH_ZAI_API_KEY")),
@@ -153,7 +153,7 @@ func testSessionAgent(env fakeEnv, large, small fantasy.LanguageModel, systemPro
153 return agent
153 return agent
154}
154}
155
155
156−func coderAgent(r *recorder.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) {
156+func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel) (SessionAgent, error) {
157 fixedTime := func() time.Time {
157 fixedTime := func() time.Time {
158 t, _ := time.Parse("1/2/2006", "1/1/2025")
158 t, _ := time.Parse("1/2/2006", "1/1/2025")
159 return t
159 return t
internal/agent/recorder_test.godeleted−116
@@ -1,116 +0,0 @@
1−package agent
2−
3−import (
4− "bytes"
5− "encoding/json"
6− "io"
7− "net/http"
8− "path/filepath"
9− "reflect"
10− "strings"
11− "testing"
12−
13− "go.yaml.in/yaml/v4"
14− "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette"
15− "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
16−)
17−
18−func newRecorder(t *testing.T) *recorder.Recorder {
19− cassetteName := filepath.Join("testdata", t.Name())
20−
21− r, err := recorder.New(
22− cassetteName,
23− recorder.WithMode(recorder.ModeRecordOnce),
24− recorder.WithMatcher(customMatcher(t)),
25− recorder.WithMarshalFunc(marshalFunc),
26− recorder.WithSkipRequestLatency(true), // disable sleep to simulate response time, makes tests faster
27− recorder.WithHook(hookRemoveHeaders, recorder.AfterCaptureHook),
28− )
29− if err != nil {
30− t.Fatalf("recorder: failed to create recorder: %v", err)
31− }
32−
33− t.Cleanup(func() {
34− if err := r.Stop(); err != nil {
35− t.Errorf("recorder: failed to stop recorder: %v", err)
36− }
37− })
38−
39− return r
40−}
41−
42−func customMatcher(t *testing.T) recorder.MatcherFunc {
43− return func(r *http.Request, i cassette.Request) bool {
44− if r.Body == nil || r.Body == http.NoBody {
45− return cassette.DefaultMatcher(r, i)
46− }
47− if r.Method != i.Method || r.URL.String() != i.URL {
48− return false
49− }
50−
51− reqBody, err := io.ReadAll(r.Body)
52− if err != nil {
53− t.Fatalf("recorder: failed to read request body")
54− }
55− r.Body.Close()
56− r.Body = io.NopCloser(bytes.NewBuffer(reqBody))
57−
58− // Some providers can sometimes generate JSON requests with keys in
59− // a different order, which means a direct string comparison will fail.
60− // Falling back to deserializing the content if we don't have a match.
61− requestContent := normalizeLineEndings(reqBody)
62− cassetteContent := normalizeLineEndings(i.Body)
63− if requestContent == cassetteContent {
64− return true
65− }
66− var content1, content2 any
67− if err := json.Unmarshal([]byte(requestContent), &content1); err != nil {
68− return false
69− }
70− if err := json.Unmarshal([]byte(cassetteContent), &content2); err != nil {
71− return false
72− }
73− return reflect.DeepEqual(content1, content2)
74− }
75−}
76−
77−func marshalFunc(in any) ([]byte, error) {
78− var buff bytes.Buffer
79− enc := yaml.NewEncoder(&buff)
80− enc.SetIndent(2)
81− enc.CompactSeqIndent()
82− if err := enc.Encode(in); err != nil {
83− return nil, err
84− }
85− return buff.Bytes(), nil
86−}
87−
88−var headersToKeep = map[string]struct{}{
89− "accept": {},
90− "content-type": {},
91− "user-agent": {},
92−}
93−
94−func hookRemoveHeaders(i *cassette.Interaction) error {
95− for k := range i.Request.Headers {
96− if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
97− delete(i.Request.Headers, k)
98− }
99− }
100− for k := range i.Response.Headers {
101− if _, ok := headersToKeep[strings.ToLower(k)]; !ok {
102− delete(i.Response.Headers, k)
103− }
104− }
105− return nil
106−}
107−
108−// normalizeLineEndings does not only replace `\r\n` into `\n`,
109−// but also replaces `\\r\\n` into `\\n`. That's because we want the content
110−// inside JSON string to be replaces as well.
111−func normalizeLineEndings[T string | []byte](s T) string {
112− str := string(s)
113− str = strings.ReplaceAll(str, "\r\n", "\n")
114− str = strings.ReplaceAll(str, `\r\n`, `\n`)
115− return str
116−}