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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WSDirector
4
+ module Protocols
5
+ # ActionCable protocol
6
+ class ActionCable < Base
7
+ PING_IGNORE = /['"]type['"]:\s*['"]ping['"]/
8
+
9
+ # Add ping ignore and make sure that we receive Welcome message
10
+ def init_client(**options)
11
+ options[:ignore] ||= [PING_IGNORE]
12
+ options[:subprotocol] ||= "actioncable-v1-json"
13
+
14
+ super(**options)
15
+
16
+ receive("data>" => {"type" => "welcome"})
17
+ log(:done) { "Welcomed" }
18
+ end
19
+
20
+ def subscribe(step)
21
+ identifier = extract_identifier(step)
22
+
23
+ log { "Subsribing to #{identifier}" }
24
+
25
+ client.send({command: "subscribe", identifier: identifier}.to_json)
26
+
27
+ begin
28
+ receive(
29
+ "data" => {"type" => "confirm_subscription", "identifier" => identifier}
30
+ )
31
+ log(:done) { "Subsribed to #{identifier}" }
32
+ rescue UnmatchedExpectationError => e
33
+ raise unless /reject_subscription/.match?(e.message)
34
+ raise UnmatchedExpectationError, "Subscription rejected to #{identifier}"
35
+ end
36
+ end
37
+
38
+ def unsubscribe(step)
39
+ identifier = extract_identifier(step)
40
+
41
+ client.send({command: "unsubscribe", identifier: identifier}.to_json)
42
+
43
+ log(nil) { "Unsubscribed from #{identifier}" }
44
+ end
45
+
46
+ def perform(step)
47
+ identifier = extract_identifier(step)
48
+ action = step.delete("action")
49
+
50
+ raise Error, "Action is missing" unless action
51
+
52
+ data = step.fetch("data", {}).merge(action: action).to_json
53
+
54
+ client.send({command: "message", data: data, identifier: identifier}.to_json)
55
+
56
+ log(nil) { "Performed #{action} on #{identifier}" }
57
+ end
58
+
59
+ def receive(step)
60
+ return super unless step.key?("channel")
61
+
62
+ identifier = extract_identifier(step)
63
+
64
+ key = step.key?("data") ? "data" : "data>"
65
+
66
+ message = step.fetch(key, {})
67
+ super(key => {"identifier" => identifier, "message" => message})
68
+ end
69
+
70
+ def receive_all(step)
71
+ messages = step["messages"]
72
+
73
+ return super if messages.nil? || messages.empty?
74
+
75
+ messages.each do |msg|
76
+ next unless msg.key?("channel")
77
+ identifier = extract_identifier(msg)
78
+
79
+ key = msg.key?("data") ? "data" : "data>"
80
+
81
+ msg[key] = {"identifier" => identifier, "message" => msg[key]}
82
+ end
83
+
84
+ super
85
+ end
86
+
87
+ private
88
+
89
+ def extract_identifier(step)
90
+ channel = step.delete("channel")
91
+ step.fetch("params", {}).merge(channel: channel).to_json
92
+ end
93
+ end
94
+ end
95
+ 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(...)
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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsdirector/scenario_reader"
4
+ require "wsdirector/clients_holder"
5
+ require "wsdirector/results_holder"
6
+ require "wsdirector/result"
7
+ require "wsdirector/task"
8
+ require "wsdirector/ext/deep_dup"
9
+
10
+ module WSDirector
11
+ def self.run(scenario, scale: 1, connection_options: {}, locals: {}, **options)
12
+ scenario = ScenarioReader.parse(scenario, scale: scale, connection_options: connection_options, locals: locals)
13
+ Runner.new(scenario, scale: scale, **options).execute
14
+ end
15
+
16
+ # Initiates all clients as a separate tasks (=threads)
17
+ class Runner
18
+ using WSDirector::Ext::DeepDup
19
+
20
+ def initialize(scenario, url:, scale: 1, sync_timeout: 5, logger: nil, colorize: false)
21
+ @scenario = scenario
22
+ @url = url
23
+ @scale = scale
24
+ @logger = logger
25
+ @colorize = colorize
26
+ @total_count = scenario["total"]
27
+ @global_holder = ClientsHolder.new(total_count, sync_timeout: sync_timeout)
28
+ @results_holder = ResultsHolder.new
29
+ end
30
+
31
+ def execute
32
+ Thread.abort_on_exception = true
33
+
34
+ num = 0
35
+
36
+ tasks = scenario["clients"].flat_map do |client_config|
37
+ name = client_config.fetch("name")
38
+ result = Result.new(name)
39
+ results_holder << result
40
+
41
+ Array.new(client_config.fetch("multiplier")) do
42
+ num += 1
43
+ id = "#{name}_#{num}"
44
+ Thread.new do
45
+ Task.new(client_config.deep_dup, id: id, colorize: colorize, global_holder: global_holder, result: result, scale: scale, logger: logger)
46
+ .run(url)
47
+ end
48
+ end
49
+ end
50
+
51
+ tasks.each(&:join)
52
+
53
+ results_holder
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :scenario, :url, :scale, :total_count, :global_holder, :results_holder, :logger, :colorize
59
+ end
60
+ end
@@ -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
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
165
+ b.local_variable_set(_1, _2)
166
+ end
167
+
168
+ b
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "wsdirector/client"
4
+ require "wsdirector/protocols"
5
+
6
+ module WSDirector
7
+ # Single client operator
8
+ class Task
9
+ attr_reader :global_holder, :client
10
+
11
+ def initialize(config, id:, global_holder:, result:, scale:, logger:, colorize: false)
12
+ @id = id
13
+ @logger = logger
14
+ @ignore = config.fetch("ignore")
15
+ @steps = config.fetch("steps")
16
+ @connection_options = config.fetch("connection_options").transform_keys(&:to_sym)
17
+ @global_holder = global_holder
18
+ @result = result
19
+
20
+ protocol_class = Protocols.get(config.fetch("protocol", "base"))
21
+ @protocol = protocol_class.new(self, scale: scale, logger: logger, id: id, color: color_for_id(id, colorize))
22
+ end
23
+
24
+ def run(url)
25
+ connect!(url, **@connection_options)
26
+
27
+ steps.each(&protocol)
28
+
29
+ result.succeed
30
+ rescue Error => e
31
+ result.failed(e.message)
32
+ end
33
+
34
+ def sampled?(step)
35
+ return true unless step["sample"]
36
+
37
+ id, max = step["id"], step["sample"]
38
+
39
+ result.track_sample(id, max)
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :steps, :result, :protocol, :id, :logger, :ignore
45
+
46
+ def connect!(url, **options)
47
+ protocol.init_client(url: url, id: id, ignore: ignore, **options)
48
+ end
49
+
50
+ def color_for_id(id, colorize)
51
+ return unless colorize
52
+
53
+ String.colors[id.hash % String.colors.size]
54
+ end
55
+ end
56
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WSDirector
4
- VERSION = "1.0.1"
4
+ VERSION = "1.0.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wsdirector-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2022-09-29 00:00:00.000000000 Z
13
+ date: 2023-01-12 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: websocket-client-simple
@@ -194,6 +194,19 @@ files:
194
194
  - LICENSE.txt
195
195
  - README.md
196
196
  - bin/wsdirector
197
+ - lib/.rbnext/2.7/wsdirector/cli.rb
198
+ - lib/.rbnext/2.7/wsdirector/client.rb
199
+ - lib/.rbnext/2.7/wsdirector/protocols/base.rb
200
+ - lib/.rbnext/2.7/wsdirector/protocols/phoenix.rb
201
+ - lib/.rbnext/2.7/wsdirector/scenario_reader.rb
202
+ - lib/.rbnext/3.0/wsdirector/ext/formatting.rb
203
+ - lib/.rbnext/3.0/wsdirector/protocols/base.rb
204
+ - lib/.rbnext/3.1/wsdirector/cli.rb
205
+ - lib/.rbnext/3.1/wsdirector/protocols/action_cable.rb
206
+ - lib/.rbnext/3.1/wsdirector/protocols/phoenix.rb
207
+ - lib/.rbnext/3.1/wsdirector/runner.rb
208
+ - lib/.rbnext/3.1/wsdirector/scenario_reader.rb
209
+ - lib/.rbnext/3.1/wsdirector/task.rb
197
210
  - lib/wsdirector-cli.rb
198
211
  - lib/wsdirector.rb
199
212
  - lib/wsdirector/cli.rb