wsdirector-core 0.0.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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