wsdirector-core 0.0.1 → 1.0.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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket-client-simple"
4
+ require "securerandom"
5
+
6
+ module WSDirector
7
+ # WebSocket client
8
+ class Client
9
+ include CGI::Escape
10
+
11
+ WAIT_WHEN_EXPECTING_EVENT = 5
12
+
13
+ attr_reader :ws, :id
14
+
15
+ # Create new WebSocket client and connect to WSDirector
16
+ # ws URL.
17
+ #
18
+ # Optionally provide an ignore pattern (to ignore incoming message,
19
+ # for example, pings)
20
+ def initialize(url:, ignore: nil, intercept: nil, subprotocol: nil, headers: nil, id: nil, query: nil, cookies: nil)
21
+ @ignore = ignore
22
+ @interceptor = intercept
23
+ @mailbox = []
24
+
25
+ has_messages = @has_messages = Concurrent::Semaphore.new(0)
26
+ messages = @messages = Queue.new
27
+ open = Concurrent::Promise.new
28
+ client = self
29
+
30
+ options = {}
31
+
32
+ if subprotocol
33
+ headers ||= {}
34
+ headers["Sec-WebSocket-Protocol"] = subprotocol
35
+ end
36
+
37
+ if cookies
38
+ headers ||= {}
39
+ headers["Cookie"] = cookies.map { "#{_1}=#{escape(_2.to_s)}" }.join("; ")
40
+ end
41
+
42
+ if query
43
+ url = "#{url}?#{query.map { "#{_1}=#{escape(_2.to_s)}" }.join("&")}"
44
+ end
45
+
46
+ options[:headers] = headers if headers
47
+
48
+ @id = id || SecureRandom.hex(6)
49
+ @ws = WebSocket::Client::Simple.connect(url, options) do |ws|
50
+ ws.on(:open) do |_event|
51
+ open.set(true)
52
+ end
53
+
54
+ ws.on :message do |msg|
55
+ data = msg.data
56
+ next if data.empty?
57
+ next if client.ignored?(data)
58
+ next if client.intercepted?(data)
59
+ messages << data
60
+ has_messages.release
61
+ end
62
+
63
+ ws.on :error do |e|
64
+ messages << Error.new("WebSocket Error #{e.inspect} #{e.backtrace}")
65
+ end
66
+ end
67
+
68
+ open.wait!(WAIT_WHEN_EXPECTING_EVENT)
69
+ rescue Errno::ECONNREFUSED
70
+ raise Error, "Failed to connect to #{url}"
71
+ end
72
+
73
+ def each_message
74
+ @mailbox.dup.each.with_index do |msg, i|
75
+ yield msg, i
76
+ end
77
+
78
+ loop do
79
+ msg = receive
80
+ @mailbox << msg
81
+ yield msg, (@mailbox.size - 1)
82
+ end
83
+ end
84
+
85
+ def receive(timeout = WAIT_WHEN_EXPECTING_EVENT)
86
+ @has_messages.try_acquire(1, timeout)
87
+ msg = @messages.pop(true)
88
+ raise msg if msg.is_a?(Exception)
89
+ msg
90
+ end
91
+
92
+ # Push message back to the mailbox (when it doesn't match the expectation)
93
+ def consumed(id)
94
+ @mailbox.delete_at(id)
95
+ end
96
+
97
+ def send(msg)
98
+ @ws.send(msg)
99
+ end
100
+
101
+ def ignored?(msg)
102
+ return false unless @ignore
103
+ @ignore.any? { |pattern| msg =~ Regexp.new(pattern) }
104
+ end
105
+
106
+ def intercepted?(msg)
107
+ return false unless @interceptor
108
+
109
+ instance_exec(msg, &@interceptor)
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ # Acts as a re-usable global barrier for a fixed number of clients.
5
+ # Barrier is reset if sucessfully passed in time.
6
+ class ClientsHolder
7
+ def initialize(count, sync_timeout: 5)
8
+ @barrier = Concurrent::CyclicBarrier.new(count)
9
+ @sync_timeout = sync_timeout
10
+ end
11
+
12
+ def wait_all
13
+ result = barrier.wait(sync_timeout)
14
+ raise Error, "Timeout (#{sync_timeout}s) exceeded for #wait_all" unless
15
+ result
16
+ barrier.reset
17
+ result
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :barrier, :sync_timeout
23
+ end
24
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ module Ext
5
+ # Extend Object through refinements
6
+ module DeepDup
7
+ refine ::Hash do
8
+ # Based on ActiveSupport http://api.rubyonrails.org/classes/Hash.html#method-i-deep_dup
9
+ def deep_dup
10
+ each_with_object(dup) do |(key, value), hash|
11
+ hash[key] = if value.is_a?(::Hash) || value.is_a?(::Array)
12
+ value.deep_dup
13
+ else
14
+ value
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ refine ::Array do
21
+ # From ActiveSupport http://api.rubyonrails.org/classes/Array.html#method-i-deep_dup
22
+ def deep_dup
23
+ map do |value|
24
+ if value.is_a?(::Hash) || value.is_a?(::Array)
25
+ value.deep_dup
26
+ else
27
+ value
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ refine ::Object do
34
+ def deep_dup
35
+ dup
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ module Ext
5
+ # Extend Object through refinements
6
+ module Formatting
7
+ refine ::Object do
8
+ def truncate(*) = itself
9
+ end
10
+
11
+ refine ::String do
12
+ def truncate(limit)
13
+ return self if size <= limit
14
+
15
+ "#{self[0..(limit - 3)]}..."
16
+ end
17
+ end
18
+
19
+ refine ::Hash do
20
+ def truncate(limit)
21
+ str = to_json
22
+
23
+ str.truncate(limit)
24
+ end
25
+ end
26
+
27
+ refine ::Float do
28
+ def duration
29
+ if self > 1
30
+ "#{truncate(2)}s"
31
+ else
32
+ "#{(self * 1000).to_i}ms"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ module Protocols
5
+ # ActionCable protocol
6
+ class ActionCable < Base
7
+ PING_IGNORE = /['"]type['"]:\s*['"]ping['"]/
8
+
9
+ # Add ping ignore and make sure that we receive Welcome message
10
+ def init_client(**options)
11
+ options[:ignore] ||= [PING_IGNORE]
12
+ options[:subprotocol] ||= "actioncable-v1-json"
13
+
14
+ super(**options)
15
+
16
+ receive("data>" => {"type" => "welcome"})
17
+ log(:done) { "Welcomed" }
18
+ end
19
+
20
+ def subscribe(step)
21
+ identifier = extract_identifier(step)
22
+
23
+ log { "Subsribing to #{identifier}" }
24
+
25
+ client.send({command: "subscribe", identifier:}.to_json)
26
+
27
+ begin
28
+ receive(
29
+ "data" => {"type" => "confirm_subscription", "identifier" => identifier}
30
+ )
31
+ log(:done) { "Subsribed to #{identifier}" }
32
+ rescue UnmatchedExpectationError => e
33
+ raise unless /reject_subscription/.match?(e.message)
34
+ raise UnmatchedExpectationError, "Subscription rejected to #{identifier}"
35
+ end
36
+ end
37
+
38
+ def unsubscribe(step)
39
+ identifier = extract_identifier(step)
40
+
41
+ client.send({command: "unsubscribe", identifier:}.to_json)
42
+
43
+ log(nil) { "Unsubscribed from #{identifier}" }
44
+ end
45
+
46
+ def perform(step)
47
+ identifier = extract_identifier(step)
48
+ action = step.delete("action")
49
+
50
+ raise Error, "Action is missing" unless action
51
+
52
+ data = step.fetch("data", {}).merge(action:).to_json
53
+
54
+ client.send({command: "message", data:, identifier:}.to_json)
55
+
56
+ log(nil) { "Performed #{action} on #{identifier}" }
57
+ end
58
+
59
+ def receive(step)
60
+ return super unless step.key?("channel")
61
+
62
+ identifier = extract_identifier(step)
63
+
64
+ key = step.key?("data") ? "data" : "data>"
65
+
66
+ message = step.fetch(key, {})
67
+ super(key => {"identifier" => identifier, "message" => message})
68
+ end
69
+
70
+ def receive_all(step)
71
+ messages = step["messages"]
72
+
73
+ return super if messages.nil? || messages.empty?
74
+
75
+ messages.each do |msg|
76
+ next unless msg.key?("channel")
77
+ identifier = extract_identifier(msg)
78
+
79
+ key = msg.key?("data") ? "data" : "data>"
80
+
81
+ msg[key] = {"identifier" => identifier, "message" => msg[key]}
82
+ end
83
+
84
+ super
85
+ end
86
+
87
+ private
88
+
89
+ def extract_identifier(step)
90
+ channel = step.delete("channel")
91
+ step.fetch("params", {}).merge(channel: channel).to_json
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "wsdirector/ext/formatting"
5
+
6
+ module WSDirector
7
+ module Protocols
8
+ using Ext::Formatting
9
+
10
+ using(Module.new do
11
+ refine ::Object do
12
+ def matches?(other)
13
+ self == other
14
+ end
15
+
16
+ def partially_matches?(other)
17
+ self == other
18
+ end
19
+ end
20
+
21
+ refine ::Array do
22
+ def matches?(actual)
23
+ if actual.is_a?(::String)
24
+ actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
25
+ end
26
+
27
+ return false unless actual
28
+
29
+ each.with_index do
30
+ return false unless _1.matches?(actual[_2])
31
+ end
32
+
33
+ true
34
+ end
35
+
36
+ def partially_matches?(actual)
37
+ if actual.is_a?(::String)
38
+ actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
39
+ end
40
+
41
+ return false unless actual
42
+
43
+ each.with_index do
44
+ return false unless _1.partially_matches?(actual[_2])
45
+ end
46
+
47
+ true
48
+ end
49
+ end
50
+
51
+ refine ::Hash do
52
+ def matches?(actual)
53
+ unless actual.is_a?(::Hash)
54
+ actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
55
+ end
56
+
57
+ return false unless actual
58
+
59
+ actual.each_key do
60
+ return false unless actual[_1].matches?(self[_1])
61
+ end
62
+
63
+ true
64
+ end
65
+
66
+ def partially_matches?(actual)
67
+ unless actual.is_a?(::Hash)
68
+ actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
69
+ end
70
+
71
+ return false unless actual
72
+
73
+ each_key do
74
+ return false unless self[_1].partially_matches?(actual[_1])
75
+ end
76
+
77
+ true
78
+ end
79
+ end
80
+ end)
81
+
82
+ class PartialMatcher
83
+ attr_reader :obj
84
+
85
+ def initialize(obj)
86
+ @obj = obj
87
+ end
88
+
89
+ def matches?(actual)
90
+ obj.partially_matches?(actual)
91
+ end
92
+
93
+ def inspect
94
+ "an object including #{obj.inspect}"
95
+ end
96
+
97
+ def truncate(...) = obj.truncate(...)
98
+ end
99
+
100
+ # Base protocol describes basic actions
101
+ class Base
102
+ include WSDirector::Utils
103
+
104
+ def initialize(task, scale: 1, logger: nil, id: nil, color: nil)
105
+ @task = task
106
+ @scale = scale
107
+ @logger = logger
108
+ @id = id
109
+ @color = color
110
+ end
111
+
112
+ def init_client(...)
113
+ log { "Connecting" }
114
+
115
+ @client = build_client(...)
116
+
117
+ log(:done) { "Connected" }
118
+ end
119
+
120
+ def handle_step(step)
121
+ type = step.delete("type")
122
+ raise Error, "Unknown step: #{type}" unless respond_to?(type)
123
+
124
+ return unless task.sampled?(step)
125
+
126
+ public_send(type, step)
127
+ end
128
+
129
+ # Sleeps for a specified number of seconds.
130
+ #
131
+ # If "shift" is provided than the initial value is
132
+ # shifted by random number from (-shift, shift).
133
+ #
134
+ # Set "debug" to true to print the delay time.
135
+ def sleep(step)
136
+ delay = step.fetch("time").to_f
137
+ shift = step.fetch("shift", 0).to_f
138
+
139
+ delay = delay - shift * rand + shift * rand
140
+
141
+ log { "Sleep for #{delay}s" }
142
+
143
+ Kernel.sleep delay if delay > 0
144
+
145
+ log(:done) { "Slept for #{delay}s" }
146
+ end
147
+
148
+ # Prints provided message
149
+ def debug(step)
150
+ with_logger do
151
+ log(nil) { step.fetch("message") }
152
+ end
153
+ end
154
+
155
+ def receive(step)
156
+ expected = step["data"] || PartialMatcher.new(step["data>"])
157
+ ordered = step["ordered"]
158
+
159
+ log { "Receive a message: #{expected.truncate(50)}" }
160
+
161
+ received = nil
162
+
163
+ client.each_message do |msg, id|
164
+ received = msg
165
+ if expected.matches?(msg)
166
+ client.consumed(id)
167
+ break
168
+ end
169
+
170
+ if ordered
171
+ raise UnmatchedExpectationError, prepare_receive_error(expected, received)
172
+ end
173
+ end
174
+
175
+ log(:done) { "Received a message: #{received&.truncate(50)}" }
176
+ rescue ThreadError
177
+ if received
178
+ raise UnmatchedExpectationError, prepare_receive_error(expected, received)
179
+ else
180
+ raise NoMessageError, "Expected to receive #{expected} but nothing has been received"
181
+ end
182
+ end
183
+
184
+ def receive_all(step)
185
+ messages = step.delete("messages")
186
+ raise ArgumentError, "Messages array must be specified" if
187
+ messages.nil? || messages.empty?
188
+
189
+ expected =
190
+ messages.map do |msg|
191
+ multiplier = parse_multiplier(msg.delete("multiplier") || "1")
192
+ [msg["data"] || PartialMatcher.new(msg["data>"]), multiplier]
193
+ end.to_h
194
+
195
+ total_expected = expected.values.sum
196
+ total_received = 0
197
+
198
+ log { "Receive #{total_expected} messages" }
199
+
200
+ total_expected.times do
201
+ received = client.receive
202
+
203
+ total_received += 1
204
+
205
+ match = expected.find { |k, _| k.matches?(received) }
206
+
207
+ raise UnexpectedMessageError, "Unexpected message received: #{received}" if
208
+ match.nil?
209
+
210
+ expected[match.first] -= 1
211
+ expected.delete(match.first) if expected[match.first].zero?
212
+ end
213
+
214
+ log(:done) { "Received #{total_expected} messages" }
215
+ rescue ThreadError
216
+ raise NoMessageError,
217
+ "Expected to receive #{total_expected} messages " \
218
+ "but received only #{total_received}"
219
+ end
220
+ # rubocop: enable Metrics/CyclomaticComplexity
221
+
222
+ def send(step)
223
+ data = step.fetch("data")
224
+ data = JSON.generate(data) if data.is_a?(Hash)
225
+
226
+ client.send(data)
227
+
228
+ log(nil) { "Sent message: #{data.truncate(50)}" }
229
+ end
230
+
231
+ def wait_all(_step)
232
+ log { "Wait all clients" }
233
+ task.global_holder.wait_all
234
+ log { "All clients" }
235
+ end
236
+
237
+ def to_proc
238
+ proc { |step| handle_step(step) }
239
+ end
240
+
241
+ private
242
+
243
+ attr_reader :client, :task, :logger, :id, :color
244
+
245
+ def build_client(...)
246
+ Client.new(...)
247
+ end
248
+
249
+ def prepare_receive_error(expected, received)
250
+ <<~MSG
251
+ Action failed: #receive
252
+ -- expected: #{expected.inspect}
253
+ ++ got: #{received} (#{received.class})
254
+ MSG
255
+ end
256
+
257
+ def log(state = :begin)
258
+ return unless logger
259
+
260
+ if state == :begin
261
+ @last_event_at = Time.now.to_f
262
+ end
263
+
264
+ done_info =
265
+ if state == :done
266
+ " (#{(Time.now.to_f - @last_event_at).duration})"
267
+ else
268
+ ""
269
+ end
270
+
271
+ msg = "[#{Time.now.strftime("%H:%I:%S.%L")}] client=#{id} #{yield}#{done_info}"
272
+
273
+ msg = msg.colorize(color) if color
274
+
275
+ logger.puts msg
276
+ end
277
+
278
+ def with_logger
279
+ return yield if logger
280
+
281
+ @logger = $stdout
282
+ yield
283
+ ensure
284
+ @logger = nil
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ module Protocols
5
+ # Phoenix Channels protocol
6
+ # See https://github.com/phoenixframework/phoenix/blob/master/lib/phoenix/socket/serializers/v2_json_serializer.ex
7
+ class Phoenix < Base
8
+ attr_reader :topics_to_join_ref
9
+ attr_accessor :refs_counter, :join_refs_counter
10
+
11
+ def initialize(...)
12
+ super
13
+
14
+ @refs_counter = 3
15
+ @join_refs_counter = 3
16
+ @topics_to_join_ref = {}
17
+ end
18
+
19
+ def init_client(**options)
20
+ options[:query] ||= {}
21
+ # Make sure we use the v2 of the protocol
22
+ options[:query][:vsn] = "2.0.0"
23
+
24
+ super(**options)
25
+ end
26
+
27
+ def join(step)
28
+ topic = step.fetch("topic")
29
+
30
+ join_ref = join_refs_counter
31
+ self.join_refs_counter += 1
32
+
33
+ ref = refs_counter
34
+ self.refs_counter += 1
35
+
36
+ log { "Joining #{topic} (##{ref})" }
37
+
38
+ cmd = new_command(:phx_join, topic, join_ref:, ref:)
39
+
40
+ client.send(cmd.to_json)
41
+
42
+ receive({"data>" => new_command(:phx_reply, topic, join_ref:, ref:, payload: {"status" => "ok"})})
43
+
44
+ log(:done) { "Joined #{topic} (##{ref})" }
45
+ end
46
+
47
+ def leave(step)
48
+ topic = step.fetch("topic")
49
+ join_ref = topics_to_join_ref.fetch(topic)
50
+
51
+ ref = refs_counter
52
+ self.refs_counter += 1
53
+
54
+ cmd = new_command(:phx_leave, topic, join_ref:, ref:)
55
+ client.send(cmd.to_json)
56
+
57
+ receive({"data>" => new_command(:phx_reply, topic, join_ref:, ref:, payload: {"status" => "ok"})})
58
+
59
+ log(nil) { "Left #{topic} (##{join_ref})" }
60
+ end
61
+
62
+ def send(step)
63
+ return super unless step.key?("topic")
64
+
65
+ ref = refs_counter
66
+ self.refs_counter += 1
67
+
68
+ topic = step.fetch("topic")
69
+ event = step.fetch("event")
70
+ payload = step["data"]
71
+
72
+ super({"data" => new_command(event, topic, payload:, ref:).to_json})
73
+ end
74
+
75
+ def receive(step)
76
+ return super unless step.key?("topic")
77
+
78
+ topic = step.fetch("topic")
79
+ event = step.fetch("event")
80
+
81
+ key = step.key?("data") ? "data" : "data>"
82
+ payload = step.fetch(key)
83
+
84
+ cmd = new_command(event, topic, payload:)
85
+
86
+ super({key => cmd})
87
+ end
88
+
89
+ private
90
+
91
+ def new_command(event, topic, join_ref: nil, ref: nil, payload: {})
92
+ [
93
+ join_ref&.to_s,
94
+ ref&.to_s,
95
+ topic.to_s,
96
+ event.to_s,
97
+ payload
98
+ ]
99
+ end
100
+ end
101
+ end
102
+ end