wsdirector-core 1.0.1 → 1.0.2

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: 9f1fd8da87e97c9a82a6f0505a8d4a7ef28a9dabb0a75809ee60b1739481e08c
4
+ data.tar.gz: 77f4c9ab2836f8f1e407c6376ee1b2d73b07ed1963770f5d38837bd24c5a598c
5
5
  SHA512:
6
- metadata.gz: f149a7b303f7fbbb60c8a5c79431f73ab89abeaa25a8a17725172d43100079d63d6ea636f36c67dbe9c51af08d9e882c49780bfd144d488b762d012148c32a38
7
- data.tar.gz: b4de9feedaa87184c63490b81c8f3c9b8b04e23918b48c8783a2f74e190498425ab58d6cb04df5e7b9ddd1292def023af3d848c43bcc39c102f13c0b8e8c5ae5
6
+ metadata.gz: 935e69a837cf42cb0bb3121a8f3f9b415534c492245b350549cf2f0f6a839f382a9e8167179c7db00c0157e4312d0a7efc6480fa9e5bac65076a3a20d9a26cd9
7
+ data.tar.gz: 61b7ee833e4a236167ef4ede04354b46d44bbc29cdc56b6caa05d3d28f5f0588131c933f3f5ab59833b1d74dfaca509b6a30f1b0bc3280591784b7cd211a448e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 1.0.2 (2023-01-12)
6
+
7
+ - Fix adding transpiled files to releases. ([@palkan][])
8
+
5
9
  ## 1.0.1 (2022-09-29)
6
10
 
7
11
  - 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,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 |_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
+ 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(*__rest__, &__block__)
113
+ log { "Connecting" }
114
+
115
+ @client = build_client(*__rest__, &__block__)
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(*__rest__, &__block__)
246
+ Client.new(*__rest__, &__block__)
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(*__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