wsdirector-core 1.0.1 → 1.0.3

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 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