-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgocksnap.go
More file actions
284 lines (221 loc) · 7.15 KB
/
gocksnap.go
File metadata and controls
284 lines (221 loc) · 7.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
package gocksnap
import (
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/h2non/gock"
)
const defaultSnapshotDirectory = "__snapshots__"
//go:embed index.html
var indexHTML string
type Request struct {
// Method is the HTTP method of the call (GET, POST, etc.)
Method string `json:"method"`
// URL is the full URL of the request.
URL string `json:"url"`
// Body is the request body, if any.
Body json.RawMessage `json:"reqBody"`
// Headers is the request headers, if any.
Headers map[string][]string `json:"headers,omitempty"`
// QueryParams is the query parameters of the request, if any.
QueryParams map[string][]string `json:"queryParams,omitempty"`
}
type MockedCall struct {
// Status is the HTTP status code of the response.
Status int `json:"status"`
// Body is the response body , if any.
Body json.RawMessage `json:"resBody"`
// MatchingHeaders is a map of headers that should match the request.
MatchingHeaders map[string][]string `json:"matchingHeaders,omitempty"`
// MatchingQueryParams is a map of query parameters that should match the request.
MatchingQueryParams map[string][]string `json:"matchingQueryParams,omitempty"`
}
// Call represents a single HTTP call in the snapshot.
type Call struct {
Request
MockedCall
}
// Snapshot holds the state of the snapshot being recorded, which can include multiple HTTP calls.
type Snapshot struct {
Partial bool `json:"partial,omitempty"`
Calls []Call `json:"calls"`
// testName used to identify the snapshot file.
testName string
// name is the name of the snapshot, used for identification in the test and in the file.
name string
// updateMode indicates if the snapshot is in update mode or in test mode.
updateMode bool
// mu is a mutex to protect access to the pending call and SSE connections.
mu sync.Mutex
// pending is the current call that is being recorded.
pending *CallPrompt
// sseConns is a map of client connections that are waiting for updates.
sseConns map[chan string]struct{}
}
func (g *Snapshot) sendMessage(msg string) {
for ch := range g.sseConns {
select {
case ch <- msg:
default:
}
}
}
func (g *Snapshot) saveToFile(t *testing.T) {
t.Helper()
data, err := json.MarshalIndent(g, "", " ")
if err != nil {
t.Fatalf("Failed to marshal snapshot '%s': %v", g.name, err)
}
_ = os.MkdirAll(defaultSnapshotDirectory, 0o750)
err = os.WriteFile(g.file(), data, 0o600)
if err != nil {
t.Fatalf("Failed to save snapshot '%s': %v", g.name, err)
}
}
func (g *Snapshot) attemptToSavePartial(t *testing.T) {
t.Helper()
if !t.Failed() || !g.updateMode || len(g.Calls) == 0 {
return
}
g.sendMessage("failedPartial")
g.Partial = true
g.saveToFile(t)
}
// Finish
func (g *Snapshot) Finish(t *testing.T) {
t.Helper()
if !g.updateMode {
if !gock.IsDone() {
t.Fatalf("Snapshot '%s' is not complete. Some requests were not mocked.", g.name)
}
return
}
// Update snapshot
g.saveToFile(t)
}
// file returns the path to the snapshot file.
func (g *Snapshot) file() string {
return filepath.Join(defaultSnapshotDirectory, strings.ReplaceAll(strings.ReplaceAll(g.testName+"-"+g.name, " ", "_"), "/", "_")+".json")
}
// promptCall sends the current request to the UI for user interaction.
func (g *Snapshot) promptCall(req *http.Request, existingCall *Call) *Call {
var bodyRaw []byte
if req.Body != nil {
bodyRaw, _ = io.ReadAll(req.Body)
}
g.mu.Lock()
fmt.Printf("Request: %s %s\n", req.Method, req.URL.String())
queryParams := req.URL.Query()
urlWithoutQuery := req.URL
urlWithoutQuery.RawQuery = ""
finalCall := make(chan *Call, 1)
g.pending = &CallPrompt{
Name: g.name,
Request: Request{
Method: req.Method,
URL: urlWithoutQuery.String(),
Body: bodyRaw,
Headers: req.Header,
QueryParams: queryParams,
},
finalCall: finalCall,
}
if existingCall != nil {
g.pending.ExistingResponse = &existingCall.MockedCall
}
// notify SSE clients
g.sendMessage("pending")
g.mu.Unlock()
return <-finalCall
}
// MatchSnapshot creates a new snapshot for the current test.
// If the snapshot file is not found, or if the environment variable UPDATE_GOCKSNAP is set to "true", it will spawn a web server to allow the user to interactively select responses for the recorded requests.
// If the snapshot file is found, it will load the existing calls and register them with gock.
// After all the calls are finished, the user should call the Finish method to save the snapshot / assert that all calls were mocked correctly.
func MatchSnapshot(t *testing.T, snapshotName string) *Snapshot {
t.Helper()
snapshot := &Snapshot{
Calls: []Call{},
testName: t.Name(),
name: snapshotName,
updateMode: os.Getenv("UPDATE_GOCKSNAP") == "true",
sseConns: make(map[chan string]struct{}),
}
var existingCalls []Call
_, err := os.Stat(snapshot.file())
if os.IsNotExist(err) {
// can't find snapshot file, so we are in update mode
t.Logf("Snapshot '%s' not found, running in update mode\n", snapshot.file())
snapshot.updateMode = true
} else {
// Load existing snapshot
data, err := os.ReadFile(snapshot.file())
if err != nil {
t.Fatalf("Failed to open snapshot '%s': %v", snapshot.file(), err)
}
err = json.Unmarshal(data, snapshot)
if err != nil {
t.Fatalf("Failed to unmarshal snapshot '%s': %v", snapshot.file(), err)
}
if snapshot.Partial {
snapshot.updateMode = true
snapshot.Partial = false // reset partial flag for the new run
}
if snapshot.updateMode {
existingCalls = snapshot.Calls
snapshot.Calls = make([]Call, 0)
t.Logf("Updating existing snapshot '%s'\n", snapshot.file())
}
}
if snapshot.updateMode {
addr, err := snapshot.startPromptServer()
if err != nil {
t.Fatalf("Failed to start prompt server for snapshot '%s': %v", snapshot.file(), err)
}
openBrowser(addr)
}
gock.Intercept()
if snapshot.updateMode {
t.Cleanup(func() { snapshot.attemptToSavePartial(t) })
var existingCall *Call
if len(existingCalls) > 0 {
existingCall = &existingCalls[0]
}
// clean up any existing mocks
for _, mock := range gock.Pending() {
mock.Disable()
}
gock.Register(snapshot.newRecordMock(existingCall))
gock.Observe(func(_ *http.Request, mock gock.Mock) {
snapshot.Calls = append(snapshot.Calls, *mock.(*recordMocker).call)
// load the next one
existingCall = nil
if len(snapshot.Calls) < len(existingCalls) {
existingCall = &existingCalls[len(snapshot.Calls)]
}
gock.Register(snapshot.newRecordMock(existingCall))
})
return snapshot
}
// Register existing calls into gock.
for _, call := range snapshot.Calls {
req := gock.NewRequest().URL(call.URL).JSON(call.Request.Body)
req.Method = strings.ToUpper(call.Method)
for key, values := range call.MockedCall.MatchingHeaders {
req.MatchHeader(key, strings.Join(values, ","))
}
for key, values := range call.MockedCall.MatchingQueryParams {
req.MatchParam(key, strings.Join(values, ","))
}
gock.Register(gock.NewMock(req, gock.NewResponse().Status(call.Status).JSON(call.MockedCall.Body)))
}
t.Logf("Loaded snapshot '%s' with %d calls", snapshot.file(), len(snapshot.Calls))
return snapshot
}