rspec-sse-matchers 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +7 -0
- data/README.md +228 -0
- data/Rakefile +16 -0
- data/examples/sse_spec.rb +213 -0
- data/lib/rspec/sse/matchers/version.rb +9 -0
- data/lib/rspec/sse/matchers.rb +554 -0
- data/renovate.json +6 -0
- data/sig/generated/rspec/sse/matchers/version.rbs +9 -0
- data/sig/generated/rspec/sse/matchers.rbs +404 -0
- metadata +73 -0
@@ -0,0 +1,554 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "matchers/version"
|
4
|
+
require "event_stream_parser"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
# @rbs!
|
8
|
+
# type ssePayload = {type: String?, data: String, id: String?, retry: Integer}
|
9
|
+
|
10
|
+
module RSpec
|
11
|
+
module Matchers
|
12
|
+
# Matches if the response body ends with "\n\n" (SSE graceful close)
|
13
|
+
# @rbs return: RSpec::SSE::Matchers::BeGracefullyClosed
|
14
|
+
def be_gracefully_closed
|
15
|
+
RSpec::SSE::Matchers::BeGracefullyClosed.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# Matches if the response indicates successfully SSE connection opened
|
19
|
+
#
|
20
|
+
# @rbs return: RSpec::SSE::Matchers::BeSuccessfullyOpened
|
21
|
+
def be_successfully_opened
|
22
|
+
RSpec::SSE::Matchers::BeSuccessfullyOpened.new
|
23
|
+
end
|
24
|
+
|
25
|
+
# Matches if the response's events match the expected events in order
|
26
|
+
#
|
27
|
+
# @rbs *events: ssePayload | Array[ssePayload]
|
28
|
+
# @rbs json: bool
|
29
|
+
# @rbs return: RSpec::SSE::Matchers::BeEvents
|
30
|
+
def be_events(*events, json: false)
|
31
|
+
RSpec::SSE::Matchers::BeEvents.new(events.flatten, json:)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Matches if the response's event types match the expected types in order
|
35
|
+
#
|
36
|
+
# @rbs *types: String | Array[String]
|
37
|
+
# @rbs return: RSpec::SSE::Matchers::BeEventTypes
|
38
|
+
def be_event_types(*types)
|
39
|
+
RSpec::SSE::Matchers::BeEventTypes.new(types.flatten)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Matches if the response's event data match the expected data in order
|
43
|
+
#
|
44
|
+
# @rbs *data: String | Array[String] | Array[Hash[String, untyped]]
|
45
|
+
# @rbs json: bool
|
46
|
+
# @rbs return: RSpec::SSE::Matchers::BeEventData
|
47
|
+
def be_event_data(*data, json: false)
|
48
|
+
RSpec::SSE::Matchers::BeEventData.new(data.flatten, json:)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Matches if the response's event IDs match the expected IDs in order
|
52
|
+
#
|
53
|
+
# @rbs *ids: String
|
54
|
+
# @rbs return: RSpec::SSE::Matchers::BeEventIds
|
55
|
+
def be_event_ids(*ids)
|
56
|
+
RSpec::SSE::Matchers::BeEventIds.new(ids.flatten)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Matches if the response's reconnection times match the expected times in order
|
60
|
+
#
|
61
|
+
# @rbs *times: Integer | Array[Integer]
|
62
|
+
# @rbs return: RSpec::SSE::Matchers::BeReconnectionTimes
|
63
|
+
def be_reconnection_times(*times)
|
64
|
+
RSpec::SSE::Matchers::BeReconnectionTimes.new(times.flatten)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Matches if the response's events contain the expected events regardless of order
|
68
|
+
#
|
69
|
+
# @rbs *events: ssePayload | Array[ssePayload]
|
70
|
+
# @rbs json: bool
|
71
|
+
# @rbs return: RSpec::SSE::Matchers::ContainExactlyEvents
|
72
|
+
def contain_exactly_events(*events, json: false)
|
73
|
+
RSpec::SSE::Matchers::ContainExactlyEvents.new(events.flatten, json:)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Matches if the response's event types contain the expected types regardless of order
|
77
|
+
#
|
78
|
+
# @rbs *types: String | Array[String]
|
79
|
+
# @rbs return: RSpec::SSE::Matchers::ContainExactlyEventTypes
|
80
|
+
def contain_exactly_event_types(*types)
|
81
|
+
RSpec::SSE::Matchers::ContainExactlyEventTypes.new(types.flatten)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Matches if the response's event data contain the expected data regardless of order
|
85
|
+
#
|
86
|
+
# @rbs *data: String | Array[String] | Array[Hash[String, untyped]]
|
87
|
+
# @rbs json: bool
|
88
|
+
# @rbs return: RSpec::SSE::Matchers::ContainExactlyEventData
|
89
|
+
def contain_exactly_event_data(*data, json: false)
|
90
|
+
RSpec::SSE::Matchers::ContainExactlyEventData.new(data.flatten, json:)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Matches if the response's event IDs contain the expected IDs regardless of order
|
94
|
+
#
|
95
|
+
# @rbs *ids: String | Array[String]
|
96
|
+
# @rbs return: RSpec::SSE::Matchers::ContainExactlyEventIds
|
97
|
+
def contain_exactly_event_ids(*ids)
|
98
|
+
RSpec::SSE::Matchers::ContainExactlyEventIds.new(ids.flatten)
|
99
|
+
end
|
100
|
+
|
101
|
+
# Matches if the response's reconnection times contain the expected times regardless of order
|
102
|
+
#
|
103
|
+
# @rbs *times: Integer | Array[Integer]
|
104
|
+
# @rbs return: RSpec::SSE::Matchers::ContainExactlyReconnectionTimes
|
105
|
+
def contain_exactly_reconnection_times(*times)
|
106
|
+
RSpec::SSE::Matchers::ContainExactlyReconnectionTimes.new(times.flatten)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Matches if the response's events include all the expected events
|
110
|
+
#
|
111
|
+
# @rbs *events: ssePayload
|
112
|
+
# @rbs json: bool
|
113
|
+
# @rbs return: RSpec::SSE::Matchers::HaveEvents
|
114
|
+
def have_events(*events, json: false)
|
115
|
+
RSpec::SSE::Matchers::HaveEvents.new(events.flatten, json:)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Matches if the response's event types include all the expected types
|
119
|
+
#
|
120
|
+
# @rbs *types: String | Array[String]
|
121
|
+
# @rbs return: RSpec::SSE::Matchers::HaveEventTypes
|
122
|
+
def have_event_types(*types)
|
123
|
+
RSpec::SSE::Matchers::HaveEventTypes.new(types.flatten)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Matches if the response's event data include all the expected data
|
127
|
+
#
|
128
|
+
# @rbs *data: String | Array[String] | Array[Hash[String, untyped]]
|
129
|
+
# @rbs json: bool
|
130
|
+
# @rbs return: RSpec::SSE::Matchers::HaveEventData
|
131
|
+
def have_event_data(*data, json: false)
|
132
|
+
RSpec::SSE::Matchers::HaveEventData.new(data.flatten, json:)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Matches if the response's event IDs include all the expected IDs
|
136
|
+
#
|
137
|
+
# @rbs *ids: String
|
138
|
+
# @rbs return: RSpec::SSE::Matchers::HaveEventIds
|
139
|
+
def have_event_ids(*ids)
|
140
|
+
RSpec::SSE::Matchers::HaveEventIds.new(ids.flatten)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Matches if the response's reconnection times include all the expected times
|
144
|
+
#
|
145
|
+
# @rbs *times: Integer
|
146
|
+
# @rbs return: RSpec::SSE::Matchers::HaveReconnectionTimes
|
147
|
+
def have_reconnection_times(*times)
|
148
|
+
RSpec::SSE::Matchers::HaveReconnectionTimes.new(times.flatten)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
module RSpec
|
154
|
+
module SSE
|
155
|
+
module Matchers
|
156
|
+
class SseParser
|
157
|
+
# @rbs body: String
|
158
|
+
# @rbs return: Array[ssePayload]
|
159
|
+
def self.parse(body)
|
160
|
+
events = []
|
161
|
+
EventStreamParser::Parser.new.feed(body) do |type, data, id, reconnection_time|
|
162
|
+
events << {
|
163
|
+
type:,
|
164
|
+
data:,
|
165
|
+
id:,
|
166
|
+
retry: reconnection_time
|
167
|
+
}
|
168
|
+
end
|
169
|
+
events
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
class BaseMatcher
|
174
|
+
# @rbs @expected: Array[Object]
|
175
|
+
# @rbs @actual: Object
|
176
|
+
# @rbs @parsed_events: Array[ssePayload]
|
177
|
+
# @rbs @json: bool
|
178
|
+
|
179
|
+
# Initialize the matcher with expected values
|
180
|
+
#
|
181
|
+
# @rbs expected: Array[Object]
|
182
|
+
# @rbs return: RSpec::SSE::Matchers::BaseMatcher
|
183
|
+
def initialize(expected, json: false)
|
184
|
+
@expected = expected
|
185
|
+
@json = json
|
186
|
+
end
|
187
|
+
|
188
|
+
# @rbs actual: Object
|
189
|
+
# @rbs return: bool
|
190
|
+
def matches?(actual)
|
191
|
+
@actual = actual
|
192
|
+
@parsed_events = SseParser.parse(actual.body)
|
193
|
+
match_condition
|
194
|
+
end
|
195
|
+
|
196
|
+
# @rbs return: String
|
197
|
+
def failure_message
|
198
|
+
"Expected #{description_for(@actual)} to #{description}"
|
199
|
+
end
|
200
|
+
|
201
|
+
# @rbs return: String
|
202
|
+
def failure_message_when_negated
|
203
|
+
"Expected #{description_for(@actual)} not to #{description}"
|
204
|
+
end
|
205
|
+
|
206
|
+
private
|
207
|
+
|
208
|
+
# @rbs obj: Object
|
209
|
+
# @rbs return: String
|
210
|
+
def description_for(obj)
|
211
|
+
return "response with events #{extract_actual.inspect}" if obj.respond_to?(:body)
|
212
|
+
obj.inspect
|
213
|
+
end
|
214
|
+
|
215
|
+
# @rbs return: Array[Object]
|
216
|
+
def extract_actual
|
217
|
+
# JSON parsing is enabled if `json: true` is passed
|
218
|
+
if @json
|
219
|
+
@parsed_events.map do |event|
|
220
|
+
parsed_event = event.dup
|
221
|
+
parsed_event[:data] = JSON.parse(event[:data])
|
222
|
+
parsed_event
|
223
|
+
end
|
224
|
+
else
|
225
|
+
@parsed_events
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
# @rbs return: String
|
230
|
+
def description
|
231
|
+
"match #{@expected.inspect}"
|
232
|
+
end
|
233
|
+
|
234
|
+
# @rbs return: bool
|
235
|
+
def match_condition
|
236
|
+
raise NotImplementedError, "Subclasses must implement match_condition"
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
class BeGracefullyClosed
|
241
|
+
# @rbs @actual: Object
|
242
|
+
|
243
|
+
# Match if the response body ends with "\n\n" (SSE graceful close)
|
244
|
+
#
|
245
|
+
# @rbs actual: Object
|
246
|
+
# @rbs return: bool
|
247
|
+
def matches?(actual)
|
248
|
+
@actual = actual
|
249
|
+
@actual.body.end_with?("\n\n")
|
250
|
+
end
|
251
|
+
|
252
|
+
# @rbs return: String
|
253
|
+
def failure_message
|
254
|
+
'Expected response body to end with "\\n\\n" (SSE graceful close)'
|
255
|
+
end
|
256
|
+
|
257
|
+
# @rbs return: String
|
258
|
+
def failure_message_when_negated
|
259
|
+
'Expected response body not to end with "\\n\\n" (SSE graceful close)'
|
260
|
+
end
|
261
|
+
|
262
|
+
# @rbs return: String
|
263
|
+
def description
|
264
|
+
"be gracefully closed"
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
class BeSuccessfullyOpened
|
269
|
+
# @rbs @actual: Object
|
270
|
+
|
271
|
+
# Match if the header and status code indicate a successful SSE connection
|
272
|
+
#
|
273
|
+
# @rbs actual: Object
|
274
|
+
# @rbs return: bool
|
275
|
+
def matches?(actual)
|
276
|
+
@actual = actual
|
277
|
+
@actual.headers["content-type"] == "text/event-stream" \
|
278
|
+
&& @actual.headers["cache-control"]&.match(/no-store/) \
|
279
|
+
&& @actual.headers["content-length"].nil? \
|
280
|
+
&& @actual.status == 200
|
281
|
+
end
|
282
|
+
|
283
|
+
# @rbs return: String
|
284
|
+
def failure_message
|
285
|
+
"Expected response header of `content-type` is `text/event-stream`, `cache-control` contains `no-store`, `content-length` does not exist, and status code is `2xx`"
|
286
|
+
end
|
287
|
+
|
288
|
+
# @rbs return: String
|
289
|
+
def failure_message_when_negated
|
290
|
+
"Expected response header of `content-type` is not `text/event-stream`, `cache-control` does not contain `no-store`, `content-length` exists, and/or status code is not `2xx`"
|
291
|
+
end
|
292
|
+
|
293
|
+
# @rbs return: String
|
294
|
+
def description
|
295
|
+
"be successfully opened"
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
class ExactMatcher < BaseMatcher
|
300
|
+
# Match if extracted actual values exactly match expected values
|
301
|
+
#
|
302
|
+
# @rbs return: bool
|
303
|
+
def match_condition
|
304
|
+
extract_actual == @expected
|
305
|
+
end
|
306
|
+
|
307
|
+
# @rbs return: String
|
308
|
+
def description
|
309
|
+
"exactly match #{@expected.inspect}"
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
# Base matcher for contain exactly matching (order doesn't matter)
|
314
|
+
class ContainExactlyMatcher < BaseMatcher
|
315
|
+
# Match if extracted actual values match expected values in any order
|
316
|
+
#
|
317
|
+
# @rbs return: bool
|
318
|
+
def match_condition
|
319
|
+
(extract_actual - @expected).empty? && (@expected - extract_actual).empty? && extract_actual.size == @expected.size
|
320
|
+
end
|
321
|
+
|
322
|
+
# @rbs return: String
|
323
|
+
def description
|
324
|
+
"contain exactly #{@expected.inspect} in any order"
|
325
|
+
end
|
326
|
+
end
|
327
|
+
|
328
|
+
# Base matcher for inclusion matching (subset)
|
329
|
+
class IncludeMatcher < BaseMatcher
|
330
|
+
# Match if extracted actual values include all expected values
|
331
|
+
#
|
332
|
+
# @rbs return: bool
|
333
|
+
def match_condition
|
334
|
+
@expected.all? { |expected_item| extract_actual.include?(expected_item) }
|
335
|
+
end
|
336
|
+
|
337
|
+
# @rbs return: String
|
338
|
+
def description
|
339
|
+
"include #{@expected.inspect}"
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
# Events matchers
|
344
|
+
|
345
|
+
# Matcher for be_events
|
346
|
+
class BeEvents < ExactMatcher
|
347
|
+
end
|
348
|
+
|
349
|
+
# Matcher for contain_exactly_events
|
350
|
+
class ContainExactlyEvents < ContainExactlyMatcher
|
351
|
+
end
|
352
|
+
|
353
|
+
# Matcher for have_events
|
354
|
+
class HaveEvents < IncludeMatcher
|
355
|
+
end
|
356
|
+
|
357
|
+
module TypeExtractor
|
358
|
+
private
|
359
|
+
|
360
|
+
# Extract event types from parsed events
|
361
|
+
#
|
362
|
+
# @rbs return: Array[String|nil]
|
363
|
+
def extract_actual
|
364
|
+
@parsed_events.map { |event| event[:type] }
|
365
|
+
end
|
366
|
+
end
|
367
|
+
|
368
|
+
# Matcher for be_event_types
|
369
|
+
class BeEventTypes < ExactMatcher
|
370
|
+
include TypeExtractor
|
371
|
+
|
372
|
+
private
|
373
|
+
|
374
|
+
# @rbs return: String
|
375
|
+
def description
|
376
|
+
"have event types exactly matching #{@expected.inspect}"
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
# Matcher for contain_exactly_event_types
|
381
|
+
class ContainExactlyEventTypes < ContainExactlyMatcher
|
382
|
+
include TypeExtractor
|
383
|
+
|
384
|
+
private
|
385
|
+
|
386
|
+
# @rbs return: String
|
387
|
+
def description
|
388
|
+
"contain exactly event types #{@expected.inspect} in any order"
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
# Matcher for have_event_types
|
393
|
+
class HaveEventTypes < IncludeMatcher
|
394
|
+
include TypeExtractor
|
395
|
+
|
396
|
+
private
|
397
|
+
|
398
|
+
# @rbs return: String
|
399
|
+
def description
|
400
|
+
"include event types #{@expected.inspect}"
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
module DataExtractor
|
405
|
+
private
|
406
|
+
|
407
|
+
# Extract event data from parsed events
|
408
|
+
# If :json option is enabled, attempt to parse the data as JSON
|
409
|
+
#
|
410
|
+
# @rbs return: Array[String|Hash[String, untyped]]
|
411
|
+
def extract_actual
|
412
|
+
@parsed_events.map do |event|
|
413
|
+
# JSON parsing is enabled if json: true is passed
|
414
|
+
if @json
|
415
|
+
JSON.parse(event[:data])
|
416
|
+
else
|
417
|
+
event[:data]
|
418
|
+
end
|
419
|
+
end
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
# Matcher for be_event_data
|
424
|
+
class BeEventData < ExactMatcher
|
425
|
+
include DataExtractor
|
426
|
+
|
427
|
+
private
|
428
|
+
|
429
|
+
# @rbs return: String
|
430
|
+
def description
|
431
|
+
"have event data exactly matching #{@expected.inspect}"
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
# Matcher for contain_exactly_event_data
|
436
|
+
class ContainExactlyEventData < ContainExactlyMatcher
|
437
|
+
include DataExtractor
|
438
|
+
|
439
|
+
private
|
440
|
+
|
441
|
+
# @rbs return: String
|
442
|
+
def description
|
443
|
+
"contain exactly event data #{@expected.inspect} in any order"
|
444
|
+
end
|
445
|
+
end
|
446
|
+
|
447
|
+
# Matcher for have_event_data
|
448
|
+
class HaveEventData < IncludeMatcher
|
449
|
+
include DataExtractor
|
450
|
+
|
451
|
+
private
|
452
|
+
|
453
|
+
# @rbs return: String
|
454
|
+
def description
|
455
|
+
"include event data #{@expected.inspect}"
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
module IdExtractor
|
460
|
+
private
|
461
|
+
|
462
|
+
# Extract event IDs from parsed events
|
463
|
+
#
|
464
|
+
# @rbs return: Array[String|nil]
|
465
|
+
def extract_actual
|
466
|
+
@parsed_events.map { |event| event[:id] }
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
# Matcher for be_event_ids
|
471
|
+
class BeEventIds < ExactMatcher
|
472
|
+
include IdExtractor
|
473
|
+
|
474
|
+
private
|
475
|
+
|
476
|
+
# @rbs return: String
|
477
|
+
def description
|
478
|
+
"have event IDs exactly matching #{@expected.inspect}"
|
479
|
+
end
|
480
|
+
end
|
481
|
+
|
482
|
+
# Matcher for contain_exactly_event_ids
|
483
|
+
class ContainExactlyEventIds < ContainExactlyMatcher
|
484
|
+
include IdExtractor
|
485
|
+
|
486
|
+
private
|
487
|
+
|
488
|
+
# @rbs return: String
|
489
|
+
def description
|
490
|
+
"contain exactly event IDs #{@expected.inspect} in any order"
|
491
|
+
end
|
492
|
+
end
|
493
|
+
|
494
|
+
# Matcher for have_event_ids
|
495
|
+
class HaveEventIds < IncludeMatcher
|
496
|
+
include IdExtractor
|
497
|
+
|
498
|
+
private
|
499
|
+
|
500
|
+
# @rbs return: String
|
501
|
+
def description
|
502
|
+
"include event IDs #{@expected.inspect}"
|
503
|
+
end
|
504
|
+
end
|
505
|
+
|
506
|
+
module RetryExtractor
|
507
|
+
private
|
508
|
+
|
509
|
+
# Extract reconnection times from parsed events
|
510
|
+
#
|
511
|
+
# @rbs return: Array[Integer|nil]
|
512
|
+
def extract_actual
|
513
|
+
@parsed_events.map { |event| event[:retry] }
|
514
|
+
end
|
515
|
+
end
|
516
|
+
|
517
|
+
# Matcher for be_reconnection_times
|
518
|
+
class BeReconnectionTimes < ExactMatcher
|
519
|
+
include RetryExtractor
|
520
|
+
|
521
|
+
private
|
522
|
+
|
523
|
+
# @rbs return: String
|
524
|
+
def description
|
525
|
+
"have reconnection times exactly matching #{@expected.inspect}"
|
526
|
+
end
|
527
|
+
end
|
528
|
+
|
529
|
+
# Matcher for contain_exactly_reconnection_times
|
530
|
+
class ContainExactlyReconnectionTimes < ContainExactlyMatcher
|
531
|
+
include RetryExtractor
|
532
|
+
|
533
|
+
private
|
534
|
+
|
535
|
+
# @rbs return: String
|
536
|
+
def description
|
537
|
+
"contain exactly reconnection times #{@expected.inspect} in any order"
|
538
|
+
end
|
539
|
+
end
|
540
|
+
|
541
|
+
# Matcher for have_reconnection_times
|
542
|
+
class HaveReconnectionTimes < IncludeMatcher
|
543
|
+
include RetryExtractor
|
544
|
+
|
545
|
+
private
|
546
|
+
|
547
|
+
# @rbs return: String
|
548
|
+
def description
|
549
|
+
"include reconnection times #{@expected.inspect}"
|
550
|
+
end
|
551
|
+
end
|
552
|
+
end
|
553
|
+
end
|
554
|
+
end
|
data/renovate.json
ADDED