wsdirector-core 1.0.1 → 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7a99e2e1bbcbf4e63a8c7fcda1653260b0294e0ffc0f3a78c279425c59336f1
4
- data.tar.gz: 7dc4ee111ce35d7de921e385cfb860500eaa465109a1bb910fa7a592dee9c86e
3
+ metadata.gz: d062f547a78c242ffc0b863fc7cd0e9405fc8b4621791ca2bb30f077a6778885
4
+ data.tar.gz: c75f03818f180eb0ee202402272189ec23a2d53ad2dd28cc9657971b9abde523
5
5
  SHA512:
6
- metadata.gz: f149a7b303f7fbbb60c8a5c79431f73ab89abeaa25a8a17725172d43100079d63d6ea636f36c67dbe9c51af08d9e882c49780bfd144d488b762d012148c32a38
7
- data.tar.gz: b4de9feedaa87184c63490b81c8f3c9b8b04e23918b48c8783a2f74e190498425ab58d6cb04df5e7b9ddd1292def023af3d848c43bcc39c102f13c0b8e8c5ae5
6
+ metadata.gz: a276cdde656fd94d4e019aa398d1bbce8509e57561bc318e9af681871d3647137f367db714c70eaaeea1b1722c81f19881c6fccb57db123885b4b6c04618ebe7
7
+ data.tar.gz: d735cf99e4c64f826ce23cf82bd66fbb2469066c3c660d05e30ed4c31610cff3498ea5d58b98558ce60ef007c34a1717326677f2457ac132a8bb43a7af401bc1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 1.0.3 (2023-10-03)
6
+
7
+ - Add `timeout` option to receive to limit the amount of time we wait for the expected message. ([@palkan][])
8
+
9
+ - Add `loop` action to multiply several actions. ([@palkan][])
10
+
11
+ ## 1.0.2 (2023-01-12)
12
+
13
+ - Fix adding transpiled files to releases. ([@palkan][])
14
+
5
15
  ## 1.0.1 (2022-09-29)
6
16
 
7
17
  - Fix `wsdirector-cli` dependencies.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "uri"
5
+
6
+ require "wsdirector"
7
+
8
+ module WSDirector
9
+ # Command line interface for WsDirector
10
+ class CLI
11
+ class Configuration
12
+ attr_accessor :ws_url, :scenario_path, :colorize, :scale,
13
+ :sync_timeout, :json_scenario, :subprotocol, :verbose
14
+
15
+ def initialize
16
+ reset!
17
+ end
18
+
19
+ def colorize?
20
+ colorize == true
21
+ end
22
+
23
+ # Restore to defaults
24
+ def reset!
25
+ @scale = 1
26
+ @colorize = $stdout.tty?
27
+ @sync_timeout = 5
28
+ end
29
+ end
30
+
31
+ attr_reader :config
32
+
33
+ def run
34
+ @config = Configuration.new
35
+
36
+ parse_args!
37
+
38
+ begin
39
+ require "colorize" if config.colorize?
40
+ rescue LoadError
41
+ config.colorize = false
42
+ warn "Install colorize to use colored output"
43
+ end
44
+
45
+ connection_options = {
46
+ subprotocol: config.subprotocol
47
+ }.compact
48
+
49
+ scenario = config.scenario_path || config.json_scenario
50
+
51
+ config.ws_url = "ws://#{config.ws_url}" unless config.ws_url.start_with?(/wss?:\/\//)
52
+
53
+ url = config.ws_url
54
+ scale = config.scale
55
+ sync_timeout = config.sync_timeout
56
+ colorize = config.colorize
57
+
58
+ logger = $stdout if config.verbose
59
+
60
+ result = WSDirector.run(
61
+ scenario,
62
+ url: url,
63
+ connection_options: connection_options,
64
+ scale: scale,
65
+ sync_timeout: sync_timeout,
66
+ logger: logger,
67
+ colorize: colorize
68
+ )
69
+
70
+ puts "\n\n" if config.verbose
71
+
72
+ result.print_summary(colorize: config.colorize?)
73
+ result.success? || exit(1)
74
+ end
75
+
76
+ private
77
+
78
+ FILE_FORMAT = /.+.(json|yml)\z/
79
+ private_constant :FILE_FORMAT
80
+
81
+ def parse_args!
82
+ parser = OptionParser.new do |opts|
83
+ opts.banner = "Usage: wsdirector scenario_path ws_url [options]"
84
+
85
+ opts.on("-s SCALE", "--scale=SCALE", Integer, "Scale factor") do |_1|
86
+ config.scale = _1
87
+ end
88
+
89
+ opts.on("-t TIMEOUT", "--timeout=TIMEOUT", Integer, "Synchronization (wait_all) timeout") do |_1|
90
+ config.sync_timeout = _1
91
+ end
92
+
93
+ opts.on("-i JSON", "--include=JSON", String, "Include JSON to parse") do |_1|
94
+ config.json_scenario = _1
95
+ end
96
+
97
+ opts.on("-u URL", "--url=URL", Object, "Websocket server URL") do |_1|
98
+ config.ws_url = _1
99
+ end
100
+
101
+ opts.on("-f PATH", "--file=PATH", String, "Scenario path") do |_1|
102
+ config.scenario_path = _1
103
+ end
104
+
105
+ opts.on("--subprotocol=VALUE", String, "WebSocket subprotocol") do |_1|
106
+ config.subprotocol = _1
107
+ end
108
+
109
+ opts.on("-c", "--[no-]color", "Colorize output") do |_1|
110
+ config.colorize = _1
111
+ end
112
+
113
+ opts.on("-v", "--version", "Print version") do
114
+ $stdout.puts WSDirector::VERSION
115
+ exit 0
116
+ end
117
+
118
+ opts.on("-vv", "Print verbose logs") do
119
+ config.verbose = true
120
+ end
121
+
122
+ opts.on("-r", "--require=PATH", "Load Ruby file (e.g., protocol)") do |_1|
123
+ Kernel.load(_1)
124
+ end
125
+ end
126
+
127
+ parser.parse!
128
+
129
+ unless config.scenario_path
130
+ config.scenario_path = ARGV.grep(FILE_FORMAT).last
131
+ end
132
+
133
+ unless config.ws_url
134
+ config.ws_url = ARGV.grep(URI::DEFAULT_PARSER.make_regexp).last
135
+ end
136
+
137
+ check_for_errors
138
+ end
139
+
140
+ def check_for_errors
141
+ if config.json_scenario.nil?
142
+ raise(Error, "Scenario is missing") unless config.scenario_path
143
+
144
+ unless File.file?(config.scenario_path)
145
+ raise(Error, "File doesn't exist # config.scenario_path}")
146
+ end
147
+ end
148
+
149
+ raise(Error, "Websocket server url is missing") unless config.ws_url
150
+ end
151
+ end
152
+ end
@@ -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, _2| "#{_1}=#{escape(_2.to_s)}" }.join("; ")
40
+ end
41
+
42
+ if query
43
+ url = "#{url}?#{query.map { |_1, _2| "#{_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,297 @@
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 |_1, _2|
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 |_1, _2|
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 |_1|
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 |_1|
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(*__rest__, &__block__) ; obj.truncate(*__rest__, &__block__); end
98
+ end
99
+
100
+ # Base protocol describes basic actions
101
+ class Base
102
+ class ReceiveTimeoutError < StandardError
103
+ end
104
+
105
+ include WSDirector::Utils
106
+
107
+ def initialize(task, scale: 1, logger: nil, id: nil, color: nil)
108
+ @task = task
109
+ @scale = scale
110
+ @logger = logger
111
+ @id = id
112
+ @color = color
113
+ end
114
+
115
+ def init_client(*__rest__, &__block__)
116
+ log { "Connecting" }
117
+
118
+ @client = build_client(*__rest__, &__block__)
119
+
120
+ log(:done) { "Connected" }
121
+ end
122
+
123
+ def handle_step(step)
124
+ type = step.delete("type")
125
+ raise Error, "Unknown step: #{type}" unless respond_to?(type)
126
+
127
+ return unless task.sampled?(step)
128
+
129
+ public_send(type, step)
130
+ end
131
+
132
+ # Sleeps for a specified number of seconds.
133
+ #
134
+ # If "shift" is provided than the initial value is
135
+ # shifted by random number from (-shift, shift).
136
+ #
137
+ # Set "debug" to true to print the delay time.
138
+ def sleep(step)
139
+ delay = step.fetch("time").to_f
140
+ shift = step.fetch("shift", 0).to_f
141
+
142
+ delay = delay - shift * rand + shift * rand
143
+
144
+ log { "Sleep for #{delay}s" }
145
+
146
+ Kernel.sleep delay if delay > 0
147
+
148
+ log(:done) { "Slept for #{delay}s" }
149
+ end
150
+
151
+ # Prints provided message
152
+ def debug(step)
153
+ with_logger do
154
+ log(nil) { step.fetch("message") }
155
+ end
156
+ end
157
+
158
+ def receive(step)
159
+ expected = step["data"] || PartialMatcher.new(step["data>"])
160
+ ordered = step["ordered"]
161
+ timeout = step.fetch("timeout", 5).to_f
162
+
163
+ log { "Receive a message in #{timeout}s: #{expected.truncate(100)}" }
164
+
165
+ start = Time.now.to_f
166
+ received = nil
167
+
168
+ client.each_message do |msg, id|
169
+ received = msg
170
+ if expected.matches?(msg)
171
+ client.consumed(id)
172
+ break
173
+ end
174
+
175
+ if ordered
176
+ raise UnmatchedExpectationError, prepare_receive_error(expected, received)
177
+ end
178
+
179
+ if Time.now.to_f - start > timeout
180
+ raise ReceiveTimeoutError
181
+ end
182
+ end
183
+
184
+ log(:done) { "Received a message: #{received&.truncate(100)}" }
185
+ rescue ThreadError, ReceiveTimeoutError
186
+ if received
187
+ raise UnmatchedExpectationError, prepare_receive_error(expected, received)
188
+ else
189
+ raise NoMessageError, "Expected to receive #{expected} but nothing has been received"
190
+ end
191
+ end
192
+
193
+ def receive_all(step)
194
+ messages = step.delete("messages")
195
+ raise ArgumentError, "Messages array must be specified" if
196
+ messages.nil? || messages.empty?
197
+
198
+ expected =
199
+ messages.map do |msg|
200
+ multiplier = parse_multiplier(msg.delete("multiplier") || "1")
201
+ [msg["data"] || PartialMatcher.new(msg["data>"]), multiplier]
202
+ end.to_h
203
+
204
+ total_expected = expected.values.sum
205
+ total_received = 0
206
+
207
+ log { "Receive #{total_expected} messages" }
208
+
209
+ total_expected.times do
210
+ received = client.receive
211
+
212
+ total_received += 1
213
+
214
+ match = expected.find { |k, _| k.matches?(received) }
215
+
216
+ raise UnexpectedMessageError, "Unexpected message received: #{received}" if
217
+ match.nil?
218
+
219
+ expected[match.first] -= 1
220
+ expected.delete(match.first) if expected[match.first].zero?
221
+ end
222
+
223
+ log(:done) { "Received #{total_expected} messages" }
224
+ rescue ThreadError
225
+ raise NoMessageError,
226
+ "Expected to receive #{total_expected} messages " \
227
+ "but received only #{total_received}"
228
+ end
229
+ # rubocop: enable Metrics/CyclomaticComplexity
230
+
231
+ def send(step)
232
+ data = step.fetch("data")
233
+ data = JSON.generate(data) if data.is_a?(Hash)
234
+
235
+ client.send(data)
236
+
237
+ log(nil) { "Sent message: #{data.truncate(50)}" }
238
+ end
239
+
240
+ def wait_all(_step)
241
+ log { "Wait all clients" }
242
+ task.global_holder.wait_all
243
+ log { "All clients" }
244
+ end
245
+
246
+ def to_proc
247
+ proc { |step| handle_step(step) }
248
+ end
249
+
250
+ private
251
+
252
+ attr_reader :client, :task, :logger, :id, :color
253
+
254
+ def build_client(*__rest__, &__block__)
255
+ Client.new(*__rest__, &__block__)
256
+ end
257
+
258
+ def prepare_receive_error(expected, received)
259
+ <<~MSG
260
+ Action failed: #receive
261
+ -- expected: #{expected.inspect}
262
+ ++ got: #{received} (#{received.class})
263
+ MSG
264
+ end
265
+
266
+ def log(state = :begin)
267
+ return unless logger
268
+
269
+ if state == :begin
270
+ @last_event_at = Time.now.to_f
271
+ end
272
+
273
+ done_info =
274
+ if state == :done
275
+ " (#{(Time.now.to_f - @last_event_at).duration})"
276
+ else
277
+ ""
278
+ end
279
+
280
+ msg = "[#{Time.now.strftime("%H:%I:%S.%L")}] client=#{id} #{yield}#{done_info}"
281
+
282
+ msg = msg.colorize(color) if color
283
+
284
+ logger.puts msg
285
+ end
286
+
287
+ def with_logger
288
+ return yield if logger
289
+
290
+ @logger = $stdout
291
+ yield
292
+ ensure
293
+ @logger = nil
294
+ end
295
+ end
296
+ end
297
+ 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(*__rest__, &__block__)
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: join_ref, ref: ref)
39
+
40
+ client.send(cmd.to_json)
41
+
42
+ receive({"data>" => new_command(:phx_reply, topic, join_ref: join_ref, 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: join_ref, ref: ref)
55
+ client.send(cmd.to_json)
56
+
57
+ receive({"data>" => new_command(:phx_reply, topic, join_ref: join_ref, 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: payload, ref: 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: 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