mieps_http-2 0.8.0

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.
@@ -0,0 +1,581 @@
1
+ require 'helper'
2
+
3
+ RSpec.describe HTTP2::Connection do
4
+ before(:each) do
5
+ @conn = Client.new
6
+ end
7
+
8
+ let(:f) { Framer.new }
9
+
10
+ context 'initialization and settings' do
11
+ it 'should raise error if first frame is not SETTINGS' do
12
+ (FRAME_TYPES - [SETTINGS]).each do |frame|
13
+ frame = set_stream_id(f.generate(frame.deep_dup), 0x0)
14
+ expect { @conn.dup << frame }.to raise_error(ProtocolError)
15
+ end
16
+
17
+ expect { @conn << f.generate(SETTINGS.dup) }.to_not raise_error
18
+ expect(@conn.state).to eq :connected
19
+ end
20
+
21
+ it 'should raise error if SETTINGS stream != 0' do
22
+ frame = set_stream_id(f.generate(SETTINGS.dup), 0x1)
23
+ expect { @conn << frame }.to raise_error(ProtocolError)
24
+ end
25
+ end
26
+
27
+ context 'settings synchronization' do
28
+ it 'should reflect outgoing settings when ack is received' do
29
+ expect(@conn.local_settings[:settings_header_table_size]).to eq 4096
30
+ @conn.settings(settings_header_table_size: 256)
31
+ expect(@conn.local_settings[:settings_header_table_size]).to eq 4096
32
+
33
+ ack = { type: :settings, stream: 0, payload: [], flags: [:ack] }
34
+ @conn << f.generate(ack)
35
+
36
+ expect(@conn.local_settings[:settings_header_table_size]).to eq 256
37
+ end
38
+
39
+ it 'should reflect incoming settings when SETTINGS is received' do
40
+ expect(@conn.remote_settings[:settings_header_table_size]).to eq 4096
41
+ settings = SETTINGS.dup
42
+ settings[:payload] = [[:settings_header_table_size, 256]]
43
+
44
+ @conn << f.generate(settings)
45
+
46
+ expect(@conn.remote_settings[:settings_header_table_size]).to eq 256
47
+ end
48
+
49
+ it 'should send SETTINGS ACK when SETTINGS is received' do
50
+ settings = SETTINGS.dup
51
+ settings[:payload] = [[:settings_header_table_size, 256]]
52
+
53
+ expect(@conn).to receive(:send) do |frame|
54
+ expect(frame[:type]).to eq :settings
55
+ expect(frame[:flags]).to eq [:ack]
56
+ expect(frame[:payload]).to eq []
57
+ end
58
+
59
+ @conn << f.generate(settings)
60
+ end
61
+ end
62
+
63
+ context 'stream management' do
64
+ it 'should initialize to default stream limit (100)' do
65
+ expect(@conn.local_settings[:settings_max_concurrent_streams]).to eq 100
66
+ end
67
+
68
+ it 'should change stream limit to received SETTINGS value' do
69
+ @conn << f.generate(SETTINGS.dup)
70
+ expect(@conn.remote_settings[:settings_max_concurrent_streams]).to eq 10
71
+ end
72
+
73
+ it 'should count open streams against stream limit' do
74
+ s = @conn.new_stream
75
+ expect(@conn.active_stream_count).to eq 0
76
+ s.receive HEADERS
77
+ expect(@conn.active_stream_count).to eq 1
78
+ end
79
+
80
+ it 'should not count reserved streams against stream limit' do
81
+ s1 = @conn.new_stream
82
+ s1.receive PUSH_PROMISE
83
+ expect(@conn.active_stream_count).to eq 0
84
+
85
+ s2 = @conn.new_stream
86
+ s2.send PUSH_PROMISE.deep_dup
87
+ expect(@conn.active_stream_count).to eq 0
88
+
89
+ # transition to half closed
90
+ s1.receive HEADERS
91
+ s2.send HEADERS.deep_dup
92
+ expect(@conn.active_stream_count).to eq 2
93
+
94
+ # transition to closed
95
+ s1.receive DATA
96
+ s2.send DATA.dup
97
+ expect(@conn.active_stream_count).to eq 0
98
+ end
99
+
100
+ it 'should not exceed stream limit set by peer' do
101
+ @conn << f.generate(SETTINGS.dup)
102
+
103
+ expect do
104
+ 10.times do
105
+ s = @conn.new_stream
106
+ s.send HEADERS.deep_dup
107
+ end
108
+ end.to_not raise_error
109
+
110
+ expect { @conn.new_stream }.to raise_error(StreamLimitExceeded)
111
+ end
112
+
113
+ it 'should initialize stream with HEADERS priority value' do
114
+ @conn << f.generate(SETTINGS.dup)
115
+
116
+ stream, headers = nil, HEADERS.dup
117
+ headers[:weight] = 20
118
+ headers[:stream_dependency] = 0
119
+ headers[:exclusive] = false
120
+
121
+ @conn.on(:stream) { |s| stream = s }
122
+ @conn << f.generate(headers)
123
+
124
+ expect(stream.weight).to eq 20
125
+ end
126
+
127
+ it 'should initialize idle stream on PRIORITY frame' do
128
+ @conn << f.generate(SETTINGS.dup)
129
+
130
+ stream = nil
131
+ @conn.on(:stream) { |s| stream = s }
132
+ @conn << f.generate(PRIORITY.dup)
133
+
134
+ expect(stream.state).to eq :idle
135
+ end
136
+ end
137
+
138
+ context 'Headers pre/post processing' do
139
+ it 'should not concatenate multiple occurences of a header field with the same name' do
140
+ input = [
141
+ ['Content-Type', 'text/html'],
142
+ ['Cache-Control', 'max-age=60, private'],
143
+ ['Cache-Control', 'must-revalidate'],
144
+ ]
145
+ expected = [
146
+ ['content-type', 'text/html'],
147
+ ['cache-control', 'max-age=60, private'],
148
+ ['cache-control', 'must-revalidate'],
149
+ ]
150
+ headers = []
151
+ @conn.on(:frame) do |bytes|
152
+ # bytes[3]: frame's type field
153
+ headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord)
154
+ end
155
+
156
+ stream = @conn.new_stream
157
+ stream.headers(input)
158
+
159
+ expect(headers.size).to eq 1
160
+ emitted = Decompressor.new.decode(headers.first[:payload])
161
+ expect(emitted).to match_array(expected)
162
+ end
163
+
164
+ it 'should not split zero-concatenated header field values' do
165
+ input = [
166
+ ['cache-control', "max-age=60, private\0must-revalidate"],
167
+ ['content-type', 'text/html'],
168
+ ['cookie', "a=b\0c=d; e=f"],
169
+ ]
170
+ expected = [
171
+ ['cache-control', "max-age=60, private\0must-revalidate"],
172
+ ['content-type', 'text/html'],
173
+ ['cookie', "a=b\0c=d; e=f"],
174
+ ]
175
+
176
+ result = nil
177
+ @conn.on(:stream) do |stream|
178
+ stream.on(:headers) { |h| result = h }
179
+ end
180
+
181
+ srv = Server.new
182
+ srv.on(:frame) { |bytes| @conn << bytes }
183
+ stream = srv.new_stream
184
+ stream.headers(input)
185
+
186
+ expect(result).to eq expected
187
+ end
188
+ end
189
+
190
+ context 'flow control' do
191
+ it 'should initialize to default flow window' do
192
+ expect(@conn.remote_window).to eq DEFAULT_FLOW_WINDOW
193
+ end
194
+
195
+ it 'should update connection and stream windows on SETTINGS' do
196
+ settings, data = SETTINGS.dup, DATA.dup
197
+ settings[:payload] = [[:settings_initial_window_size, 1024]]
198
+ data[:payload] = 'x' * 2048
199
+
200
+ stream = @conn.new_stream
201
+
202
+ stream.send HEADERS.deep_dup
203
+ stream.send data
204
+ expect(stream.remote_window).to eq(DEFAULT_FLOW_WINDOW - 2048)
205
+ expect(@conn.remote_window).to eq(DEFAULT_FLOW_WINDOW - 2048)
206
+
207
+ @conn << f.generate(settings)
208
+ expect(@conn.remote_window).to eq(-1024)
209
+ expect(stream.remote_window).to eq(-1024)
210
+ end
211
+
212
+ it 'should initialize streams with window specified by peer' do
213
+ settings = SETTINGS.dup
214
+ settings[:payload] = [[:settings_initial_window_size, 1024]]
215
+
216
+ @conn << f.generate(settings)
217
+ expect(@conn.new_stream.remote_window).to eq 1024
218
+ end
219
+
220
+ it 'should observe connection flow control' do
221
+ settings, data = SETTINGS.dup, DATA.dup
222
+ settings[:payload] = [[:settings_initial_window_size, 1000]]
223
+
224
+ @conn << f.generate(settings)
225
+ s1 = @conn.new_stream
226
+ s2 = @conn.new_stream
227
+
228
+ s1.send HEADERS.deep_dup
229
+ s1.send data.merge(payload: 'x' * 900)
230
+ expect(@conn.remote_window).to eq 100
231
+
232
+ s2.send HEADERS.deep_dup
233
+ s2.send data.merge(payload: 'x' * 200)
234
+ expect(@conn.remote_window).to eq 0
235
+ expect(@conn.buffered_amount).to eq 100
236
+
237
+ @conn << f.generate(WINDOW_UPDATE.merge(stream: 0, increment: 1000))
238
+ expect(@conn.buffered_amount).to eq 0
239
+ expect(@conn.remote_window).to eq 900
240
+ end
241
+ end
242
+
243
+ context 'framing' do
244
+ it 'should buffer incomplete frames' do
245
+ settings = SETTINGS.dup
246
+ settings[:payload] = [[:settings_initial_window_size, 1000]]
247
+ @conn << f.generate(settings)
248
+
249
+ frame = f.generate(WINDOW_UPDATE.merge(stream: 0, increment: 1000))
250
+ @conn << frame
251
+ expect(@conn.remote_window).to eq 2000
252
+
253
+ @conn << frame.slice!(0, 1)
254
+ @conn << frame
255
+ expect(@conn.remote_window).to eq 3000
256
+ end
257
+
258
+ it 'should decompress header blocks regardless of stream state' do
259
+ req_headers = [
260
+ ['content-length', '20'],
261
+ ['x-my-header', 'first'],
262
+ ]
263
+
264
+ cc = Compressor.new
265
+ headers = HEADERS.dup
266
+ headers[:payload] = cc.encode(req_headers)
267
+
268
+ @conn << f.generate(SETTINGS.dup)
269
+ @conn.on(:stream) do |stream|
270
+ expect(stream).to receive(:<<) do |frame|
271
+ expect(frame[:payload]).to eq req_headers
272
+ end
273
+ end
274
+
275
+ @conn << f.generate(headers)
276
+ end
277
+
278
+ it 'should decode non-contiguous header blocks' do
279
+ req_headers = [
280
+ ['content-length', '15'],
281
+ ['x-my-header', 'first'],
282
+ ]
283
+
284
+ cc = Compressor.new
285
+ h1, h2 = HEADERS.dup, CONTINUATION.dup
286
+
287
+ # Header block fragment might not complete for decompression
288
+ payload = cc.encode(req_headers)
289
+ h1[:payload] = payload.slice!(0, payload.size / 2) # first half
290
+ h1[:stream] = 5
291
+ h1[:flags] = []
292
+
293
+ h2[:payload] = payload # the remaining
294
+ h2[:stream] = 5
295
+
296
+ @conn << f.generate(SETTINGS.dup)
297
+ @conn.on(:stream) do |stream|
298
+ expect(stream).to receive(:<<) do |frame|
299
+ expect(frame[:payload]).to eq req_headers
300
+ end
301
+ end
302
+
303
+ @conn << f.generate(h1)
304
+ @conn << f.generate(h2)
305
+ end
306
+
307
+ it 'should require that split header blocks are a contiguous sequence' do
308
+ headers = HEADERS.dup
309
+ headers[:flags] = []
310
+
311
+ @conn << f.generate(SETTINGS.dup)
312
+ @conn << f.generate(headers)
313
+ (FRAME_TYPES - [CONTINUATION]).each do |frame|
314
+ expect { @conn << f.generate(frame.deep_dup) }.to raise_error(ProtocolError)
315
+ end
316
+ end
317
+
318
+ it 'should raise compression error on encode of invalid frame' do
319
+ @conn << f.generate(SETTINGS.dup)
320
+ stream = @conn.new_stream
321
+
322
+ expect do
323
+ stream.headers('name' => Float::INFINITY)
324
+ end.to raise_error(CompressionError)
325
+ end
326
+
327
+ it 'should raise connection error on decode of invalid frame' do
328
+ @conn << f.generate(SETTINGS.dup)
329
+ frame = f.generate(DATA.dup) # Receiving DATA on unopened stream 1 is an error.
330
+ # Connection errors emit protocol error frames
331
+ expect { @conn << frame }.to raise_error(ProtocolError)
332
+ end
333
+
334
+ it 'should emit encoded frames via on(:frame)' do
335
+ bytes = nil
336
+ @conn.on(:frame) { |d| bytes = d }
337
+ @conn.settings(settings_max_concurrent_streams: 10,
338
+ settings_initial_window_size: 0x7fffffff)
339
+
340
+ expect(bytes).to eq f.generate(SETTINGS.dup)
341
+ end
342
+
343
+ it 'should compress stream headers' do
344
+ @conn.on(:frame) do |bytes|
345
+ expect(bytes).not_to include('get')
346
+ expect(bytes).not_to include('http')
347
+ expect(bytes).not_to include('www.example.org') # should be huffman encoded
348
+ end
349
+
350
+ stream = @conn.new_stream
351
+ stream.headers(':method' => 'get',
352
+ ':scheme' => 'http',
353
+ ':authority' => 'www.example.org',
354
+ ':path' => '/resource')
355
+ end
356
+
357
+ it 'should generate CONTINUATION if HEADERS is too long' do
358
+ headers = []
359
+ @conn.on(:frame) do |bytes|
360
+ # bytes[3]: frame's type field
361
+ headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord)
362
+ end
363
+
364
+ stream = @conn.new_stream
365
+ stream.headers({
366
+ ':method' => 'get',
367
+ ':scheme' => 'http',
368
+ ':authority' => 'www.example.org',
369
+ ':path' => '/resource',
370
+ 'custom' => 'q' * 44_000,
371
+ }, end_stream: true)
372
+ expect(headers.size).to eq 3
373
+ expect(headers[0][:type]).to eq :headers
374
+ expect(headers[1][:type]).to eq :continuation
375
+ expect(headers[2][:type]).to eq :continuation
376
+ expect(headers[0][:flags]).to eq [:end_stream]
377
+ expect(headers[1][:flags]).to eq []
378
+ expect(headers[2][:flags]).to eq [:end_headers]
379
+ end
380
+
381
+ it 'should not generate CONTINUATION if HEADERS fits exactly in a frame' do
382
+ headers = []
383
+ @conn.on(:frame) do |bytes|
384
+ # bytes[3]: frame's type field
385
+ headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord)
386
+ end
387
+
388
+ stream = @conn.new_stream
389
+ stream.headers({
390
+ ':method' => 'get',
391
+ ':scheme' => 'http',
392
+ ':authority' => 'www.example.org',
393
+ ':path' => '/resource',
394
+ 'custom' => 'q' * 18_682, # this number should be updated when Huffman table is changed
395
+ }, end_stream: true)
396
+ expect(headers[0][:length]).to eq @conn.remote_settings[:settings_max_frame_size]
397
+ expect(headers.size).to eq 1
398
+ expect(headers[0][:type]).to eq :headers
399
+ expect(headers[0][:flags]).to include(:end_headers)
400
+ expect(headers[0][:flags]).to include(:end_stream)
401
+ end
402
+
403
+ it 'should not generate CONTINUATION if HEADERS fits exactly in a frame' do
404
+ headers = []
405
+ @conn.on(:frame) do |bytes|
406
+ # bytes[3]: frame's type field
407
+ headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord)
408
+ end
409
+
410
+ stream = @conn.new_stream
411
+ stream.headers({
412
+ ':method' => 'get',
413
+ ':scheme' => 'http',
414
+ ':authority' => 'www.example.org',
415
+ ':path' => '/resource',
416
+ 'custom' => 'q' * 18_682, # this number should be updated when Huffman table is changed
417
+ }, end_stream: true)
418
+ expect(headers[0][:length]).to eq @conn.remote_settings[:settings_max_frame_size]
419
+ expect(headers.size).to eq 1
420
+ expect(headers[0][:type]).to eq :headers
421
+ expect(headers[0][:flags]).to include(:end_headers)
422
+ expect(headers[0][:flags]).to include(:end_stream)
423
+ end
424
+
425
+ it 'should generate CONTINUATION if HEADERS exceed the max payload by one byte' do
426
+ headers = []
427
+ @conn.on(:frame) do |bytes|
428
+ headers << f.parse(bytes) if [1, 5, 9].include?(bytes[3].ord)
429
+ end
430
+
431
+ stream = @conn.new_stream
432
+ stream.headers({
433
+ ':method' => 'get',
434
+ ':scheme' => 'http',
435
+ ':authority' => 'www.example.org',
436
+ ':path' => '/resource',
437
+ 'custom' => 'q' * 18_683, # this number should be updated when Huffman table is changed
438
+ }, end_stream: true)
439
+ expect(headers[0][:length]).to eq @conn.remote_settings[:settings_max_frame_size]
440
+ expect(headers[1][:length]).to eq 1
441
+ expect(headers.size).to eq 2
442
+ expect(headers[0][:type]).to eq :headers
443
+ expect(headers[1][:type]).to eq :continuation
444
+ expect(headers[0][:flags]).to eq [:end_stream]
445
+ expect(headers[1][:flags]).to eq [:end_headers]
446
+ end
447
+ end
448
+
449
+ context 'connection management' do
450
+ it 'should raise error on invalid connection header' do
451
+ srv = Server.new
452
+ expect { srv << f.generate(SETTINGS.dup) }.to raise_error(HandshakeError)
453
+
454
+ srv = Server.new
455
+ expect do
456
+ srv << CONNECTION_PREFACE_MAGIC
457
+ srv << f.generate(SETTINGS.dup)
458
+ end.to_not raise_error
459
+ end
460
+
461
+ it 'should respond to PING frames' do
462
+ @conn << f.generate(SETTINGS.dup)
463
+ expect(@conn).to receive(:send) do |frame|
464
+ expect(frame[:type]).to eq :ping
465
+ expect(frame[:flags]).to eq [:ack]
466
+ expect(frame[:payload]).to eq '12345678'
467
+ end
468
+
469
+ @conn << f.generate(PING.dup)
470
+ end
471
+
472
+ it 'should fire callback on PONG' do
473
+ @conn << f.generate(SETTINGS.dup)
474
+
475
+ pong = nil
476
+ @conn.ping('12345678') { |d| pong = d }
477
+ @conn << f.generate(PONG.dup)
478
+ expect(pong).to eq '12345678'
479
+ end
480
+
481
+ it 'should fire callback on receipt of GOAWAY' do
482
+ last_stream, payload, error = nil
483
+ @conn << f.generate(SETTINGS.dup)
484
+ @conn.on(:goaway) do |s, e, p|
485
+ last_stream = s
486
+ error = e
487
+ payload = p
488
+ end
489
+ @conn << f.generate(GOAWAY.merge(last_stream: 17, payload: 'test'))
490
+
491
+ expect(last_stream).to eq 17
492
+ expect(error).to eq :no_error
493
+ expect(payload).to eq 'test'
494
+ end
495
+
496
+ it 'should raise error when opening new stream after sending GOAWAY' do
497
+ @conn.goaway
498
+ expect { @conn.new_stream }.to raise_error(ConnectionClosed)
499
+ end
500
+
501
+ it 'should raise error when opening new stream after receiving GOAWAY' do
502
+ @conn << f.generate(SETTINGS.dup)
503
+ @conn << f.generate(GOAWAY.dup)
504
+ expect { @conn.new_stream }.to raise_error(ConnectionClosed)
505
+ end
506
+
507
+ it 'should process connection management frames after GOAWAY' do
508
+ @conn << f.generate(SETTINGS.dup)
509
+ @conn << f.generate(HEADERS.dup)
510
+ @conn << f.generate(GOAWAY.dup)
511
+ @conn << f.generate(HEADERS.merge(stream: 7))
512
+ @conn << f.generate(PUSH_PROMISE.dup)
513
+
514
+ expect(@conn.active_stream_count).to eq 1
515
+ end
516
+
517
+ it 'should raise error on frame for invalid stream ID' do
518
+ @conn << f.generate(SETTINGS.dup)
519
+
520
+ expect do
521
+ @conn << f.generate(DATA.dup.merge(stream: 31))
522
+ end.to raise_error(ProtocolError)
523
+ end
524
+
525
+ it 'should send GOAWAY frame on connection error' do
526
+ stream = @conn.new_stream
527
+
528
+ expect(@conn).to receive(:encode) do |frame|
529
+ expect(frame[:type]).to eq :settings
530
+ [frame]
531
+ end
532
+ expect(@conn).to receive(:encode) do |frame|
533
+ expect(frame[:type]).to eq :goaway
534
+ expect(frame[:last_stream]).to eq stream.id
535
+ expect(frame[:error]).to eq :protocol_error
536
+ [frame]
537
+ end
538
+
539
+ expect { @conn << f.generate(DATA.dup) }.to raise_error(ProtocolError)
540
+ end
541
+ end
542
+
543
+ context 'API' do
544
+ it '.settings should emit SETTINGS frames' do
545
+ expect(@conn).to receive(:send) do |frame|
546
+ expect(frame[:type]).to eq :settings
547
+ expect(frame[:payload]).to eq([
548
+ [:settings_max_concurrent_streams, 10],
549
+ [:settings_initial_window_size, 0x7fffffff],
550
+ ])
551
+ expect(frame[:stream]).to eq 0
552
+ end
553
+
554
+ @conn.settings(settings_max_concurrent_streams: 10,
555
+ settings_initial_window_size: 0x7fffffff)
556
+ end
557
+
558
+ it '.ping should generate PING frames' do
559
+ expect(@conn).to receive(:send) do |frame|
560
+ expect(frame[:type]).to eq :ping
561
+ expect(frame[:payload]).to eq 'somedata'
562
+ end
563
+
564
+ @conn.ping('somedata')
565
+ end
566
+
567
+ it '.goaway should generate GOAWAY frame with last processed stream ID' do
568
+ @conn << f.generate(SETTINGS.dup)
569
+ @conn << f.generate(HEADERS.merge(stream: 17))
570
+
571
+ expect(@conn).to receive(:send) do |frame|
572
+ expect(frame[:type]).to eq :goaway
573
+ expect(frame[:last_stream]).to eq 17
574
+ expect(frame[:error]).to eq :internal_error
575
+ expect(frame[:payload]).to eq 'payload'
576
+ end
577
+
578
+ @conn.goaway(:internal_error, 'payload')
579
+ end
580
+ end
581
+ end
@@ -0,0 +1,54 @@
1
+ require 'helper'
2
+
3
+ RSpec.describe HTTP2::Emitter do
4
+ class Worker
5
+ include Emitter
6
+ end
7
+
8
+ before(:each) do
9
+ @w = Worker.new
10
+ @cnt = 0
11
+ end
12
+
13
+ it 'should raise error on missing callback' do
14
+ expect { @w.on(:a) {} }.to_not raise_error
15
+ expect { @w.on(:a) }.to raise_error
16
+ end
17
+
18
+ it 'should allow multiple callbacks on single event' do
19
+ @w.on(:a) { @cnt += 1 }
20
+ @w.on(:a) { @cnt += 1 }
21
+ @w.emit(:a)
22
+
23
+ expect(@cnt).to eq 2
24
+ end
25
+
26
+ it 'should execute callback with optional args' do
27
+ args = nil
28
+ @w.on(:a) { |a| args = a }
29
+ @w.emit(:a, 123)
30
+
31
+ expect(args).to eq 123
32
+ end
33
+
34
+ it 'should pass emitted callbacks to listeners' do
35
+ @w.on(:a) { |&block| block.call }
36
+ @w.once(:a) { |&block| block.call }
37
+ @w.emit(:a) { @cnt += 1 }
38
+
39
+ expect(@cnt).to eq 2
40
+ end
41
+
42
+ it 'should allow events with no callbacks' do
43
+ expect { @w.emit(:missing) }.to_not raise_error
44
+ end
45
+
46
+ it 'should execute callback exactly once' do
47
+ @w.on(:a) { @cnt += 1 }
48
+ @w.once(:a) { @cnt += 1 }
49
+ @w.emit(:a)
50
+ @w.emit(:a)
51
+
52
+ expect(@cnt).to eq 3
53
+ end
54
+ end