wsdirector-core 1.0.0 → 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.0"
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.0
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-19 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