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.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "wsdirector/ext/deep_dup"
6
+
7
+ module WSDirector
8
+ # Read and parse different scenarios
9
+ class ScenarioReader
10
+ using WSDirector::Ext::DeepDup
11
+
12
+ include WSDirector::Utils
13
+
14
+ class << self
15
+ def parse(scenario, **options)
16
+ new(**options).parse(scenario)
17
+ end
18
+ end
19
+
20
+ attr_reader :default_connections_options, :locals
21
+
22
+ def initialize(scale: 1, connection_options: {}, locals: {})
23
+ @scale = scale
24
+ @default_connections_options = connection_options
25
+ @locals = locals
26
+ end
27
+
28
+ def parse(scenario)
29
+ contents =
30
+ if scenario.is_a?(String)
31
+ if File.file?(scenario)
32
+ parse_file(scenario)
33
+ else
34
+ parse_from_str(scenario).then do |_1|
35
+ next _1 if _1.is_a?(Array)
36
+ [_1]
37
+ end
38
+ end.flatten
39
+ else
40
+ scenario
41
+ end
42
+
43
+ contents.map! do |item|
44
+ item.is_a?(String) ? {item => {}} : item
45
+ end
46
+
47
+ if contents.first&.key?("client")
48
+ contents = transform_with_loop(contents, multiple: true)
49
+ parse_multiple_scenarios(contents)
50
+ else
51
+ contents = transform_with_loop(contents)
52
+ {"total" => 1, "clients" => [parse_simple_scenario(contents)]}
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ JSON_FILE_FORMAT = /.+.(json)\z/
59
+ private_constant :JSON_FILE_FORMAT
60
+
61
+ def parse_from_str(contents)
62
+ JSON.parse(contents)
63
+ rescue JSON::ParserError
64
+ parse_yaml(contents)
65
+ end
66
+
67
+ def parse_file(file)
68
+ if file.match?(JSON_FILE_FORMAT)
69
+ JSON.parse(File.read(file))
70
+ else
71
+ parse_yaml(file)
72
+ end
73
+ end
74
+
75
+ def parse_yaml(path)
76
+ contents = File.file?(path) ? File.read(path) : path
77
+
78
+ if defined?(ERB)
79
+ contents = ERB.new(contents).result(erb_context)
80
+ end
81
+
82
+ ::YAML.load(contents, aliases: true, permitted_classes: [Date, Time, Regexp]) || {}
83
+ rescue ArgumentError
84
+ ::YAML.load(contents) || {}
85
+ end
86
+
87
+ def handle_steps(steps)
88
+ steps.flat_map.with_index do |step, id|
89
+ if step.is_a?(Hash)
90
+ type, data = step.to_a.first
91
+
92
+ data["sample"] = [1, parse_multiplier(data["sample"])].max if data["sample"]
93
+
94
+ multiplier = parse_multiplier(data.delete("multiplier") || "1")
95
+ Array.new(multiplier) { {"type" => type, "id" => id}.merge(data) }
96
+ else
97
+ {"type" => step, "id" => id}
98
+ end
99
+ end
100
+ end
101
+
102
+ def parse_simple_scenario(
103
+ steps,
104
+ multiplier: 1, name: "default", ignore: nil, protocol: "base",
105
+ connection_options: {}
106
+ )
107
+ {
108
+ "multiplier" => multiplier,
109
+ "steps" => handle_steps(steps),
110
+ "name" => name,
111
+ "ignore" => ignore,
112
+ "protocol" => protocol,
113
+ "connection_options" => default_connections_options.merge(connection_options)
114
+ }
115
+ end
116
+
117
+ def parse_multiple_scenarios(definitions)
118
+ total_count = 0
119
+ clients = definitions.map.with_index do |client, i|
120
+ _, client = client.to_a.first
121
+ multiplier = parse_multiplier(client.delete("multiplier") || "1")
122
+ name = client.delete("name") || (i + 1).to_s
123
+ connection_options = client.delete("connection_options") || {}
124
+ total_count += multiplier
125
+ ignore = parse_ignore(client.fetch("ignore", nil))
126
+ protocol = client.fetch("protocol", "base")
127
+
128
+ parse_simple_scenario(
129
+ client.fetch("actions", []),
130
+ multiplier: multiplier,
131
+ name: name,
132
+ ignore: ignore,
133
+ protocol: protocol,
134
+ connection_options: connection_options
135
+ )
136
+ end
137
+ {"total" => total_count, "clients" => clients}
138
+ end
139
+
140
+ def transform_with_loop(contents, multiple: false)
141
+ contents.flat_map do |content|
142
+ loop_data = content.dig("client", "loop") || content.dig("loop")
143
+ next content unless loop_data
144
+
145
+ loop_multiplier = parse_multiplier(loop_data["multiplier"] || "1")
146
+
147
+ if multiple
148
+ content["client"]["actions"] = (loop_data["actions"] * loop_multiplier).map(&:deep_dup)
149
+ content
150
+ else
151
+ loop_data["actions"] * loop_multiplier
152
+ end
153
+ end
154
+ end
155
+
156
+ def parse_ignore(str)
157
+ return unless str
158
+
159
+ Array(str)
160
+ end
161
+
162
+ def erb_context
163
+ binding.then do |b|
164
+ locals.each do |_1, _2|
165
+ b.local_variable_set(_1, _2)
166
+ end
167
+
168
+ b
169
+ end
170
+ end
171
+ end
172
+ 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; end
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,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(...); 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(...)
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,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
86
+ config.scale = _1
87
+ end
88
+
89
+ opts.on("-t TIMEOUT", "--timeout=TIMEOUT", Integer, "Synchronization (wait_all) timeout") do
90
+ config.sync_timeout = _1
91
+ end
92
+
93
+ opts.on("-i JSON", "--include=JSON", String, "Include JSON to parse") do
94
+ config.json_scenario = _1
95
+ end
96
+
97
+ opts.on("-u URL", "--url=URL", Object, "Websocket server URL") do
98
+ config.ws_url = _1
99
+ end
100
+
101
+ opts.on("-f PATH", "--file=PATH", String, "Scenario path") do
102
+ config.scenario_path = _1
103
+ end
104
+
105
+ opts.on("--subprotocol=VALUE", String, "WebSocket subprotocol") do
106
+ config.subprotocol = _1
107
+ end
108
+
109
+ opts.on("-c", "--[no-]color", "Colorize output") do
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
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