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 +4 -4
- data/CHANGELOG.md +4 -0
- data/lib/.rbnext/2.7/wsdirector/cli.rb +152 -0
- data/lib/.rbnext/2.7/wsdirector/client.rb +112 -0
- data/lib/.rbnext/2.7/wsdirector/protocols/base.rb +288 -0
- data/lib/.rbnext/2.7/wsdirector/protocols/phoenix.rb +102 -0
- data/lib/.rbnext/2.7/wsdirector/scenario_reader.rb +172 -0
- data/lib/.rbnext/3.0/wsdirector/ext/formatting.rb +38 -0
- data/lib/.rbnext/3.0/wsdirector/protocols/base.rb +288 -0
- data/lib/.rbnext/3.1/wsdirector/cli.rb +152 -0
- data/lib/.rbnext/3.1/wsdirector/protocols/action_cable.rb +95 -0
- data/lib/.rbnext/3.1/wsdirector/protocols/phoenix.rb +102 -0
- data/lib/.rbnext/3.1/wsdirector/runner.rb +60 -0
- data/lib/.rbnext/3.1/wsdirector/scenario_reader.rb +172 -0
- data/lib/.rbnext/3.1/wsdirector/task.rb +56 -0
- data/lib/wsdirector/version.rb +1 -1
- metadata +15 -2
@@ -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
|
data/lib/wsdirector/version.rb
CHANGED
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.
|
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:
|
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
|