wsdirector-core 1.0.1 → 1.0.2

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