wsdirector-core 0.0.1 → 1.0.0
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 +5 -5
- data/CHANGELOG.md +144 -0
- data/LICENSE.txt +1 -1
- data/README.md +330 -15
- data/bin/wsdirector +15 -0
- data/lib/wsdirector/cli.rb +152 -0
- data/lib/wsdirector/client.rb +112 -0
- data/lib/wsdirector/clients_holder.rb +24 -0
- data/lib/wsdirector/ext/deep_dup.rb +40 -0
- data/lib/wsdirector/ext/formatting.rb +38 -0
- data/lib/wsdirector/protocols/action_cable.rb +95 -0
- data/lib/wsdirector/protocols/base.rb +288 -0
- data/lib/wsdirector/protocols/phoenix.rb +102 -0
- data/lib/wsdirector/protocols.rb +38 -0
- data/lib/wsdirector/result.rb +56 -0
- data/lib/wsdirector/results_holder.rb +54 -0
- data/lib/wsdirector/runner.rb +60 -0
- data/lib/wsdirector/scenario_reader.rb +172 -0
- data/lib/wsdirector/snapshot.rb +37 -0
- data/lib/wsdirector/task.rb +56 -0
- data/lib/wsdirector/utils.rb +17 -0
- data/lib/wsdirector/version.rb +5 -0
- data/lib/wsdirector-cli.rb +8 -0
- data/lib/wsdirector.rb +22 -0
- metadata +170 -28
- data/.gitignore +0 -9
- data/.travis.yml +0 -5
- data/Gemfile +0 -6
- data/Rakefile +0 -10
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/lib/wsdirector/core/version.rb +0 -5
- data/lib/wsdirector/core.rb +0 -7
- data/wsdirector-core.gemspec +0 -26
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "wsdirector/protocols/base"
|
4
|
+
require "wsdirector/protocols/action_cable"
|
5
|
+
require "wsdirector/protocols/phoenix"
|
6
|
+
|
7
|
+
module WSDirector
|
8
|
+
ID2CLASS = {
|
9
|
+
"base" => "Base",
|
10
|
+
"action_cable" => "ActionCable",
|
11
|
+
"phoenix" => "Phoenix"
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
module Protocols # :nodoc:
|
15
|
+
# Raised when received not expected message
|
16
|
+
class UnmatchedExpectationError < WSDirector::Error; end
|
17
|
+
|
18
|
+
# Raised when received message is unexpected
|
19
|
+
class UnexpectedMessageError < WSDirector::Error; end
|
20
|
+
|
21
|
+
# Raised when nothing has been received
|
22
|
+
class NoMessageError < WSDirector::Error; end
|
23
|
+
|
24
|
+
class << self
|
25
|
+
def get(id)
|
26
|
+
class_name = if ID2CLASS.key?(id)
|
27
|
+
ID2CLASS.fetch(id)
|
28
|
+
else
|
29
|
+
id
|
30
|
+
end
|
31
|
+
|
32
|
+
const_get(class_name)
|
33
|
+
rescue NameError
|
34
|
+
raise Error, "Unknown protocol: #{id}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WSDirector
|
4
|
+
# Handle results from all clients from the group
|
5
|
+
class Result
|
6
|
+
attr_reader :group, :errors
|
7
|
+
|
8
|
+
def initialize(group)
|
9
|
+
@group = group
|
10
|
+
@errors = Concurrent::Array.new
|
11
|
+
|
12
|
+
@all = Concurrent::AtomicFixnum.new(0)
|
13
|
+
@failures = Concurrent::AtomicFixnum.new(0)
|
14
|
+
|
15
|
+
@sampling_mutex = Mutex.new
|
16
|
+
@sampling_counter = Hash.new { |h, k| h[k] = 0 }
|
17
|
+
end
|
18
|
+
|
19
|
+
# Called when client successfully finished it's work
|
20
|
+
def succeed
|
21
|
+
all.increment
|
22
|
+
end
|
23
|
+
|
24
|
+
# Called when client failed
|
25
|
+
def failed(error_message)
|
26
|
+
errors << error_message
|
27
|
+
all.increment
|
28
|
+
failures.increment
|
29
|
+
end
|
30
|
+
|
31
|
+
def success?
|
32
|
+
failures.value.zero?
|
33
|
+
end
|
34
|
+
|
35
|
+
def total_count
|
36
|
+
all.value
|
37
|
+
end
|
38
|
+
|
39
|
+
def failures_count
|
40
|
+
failures.value
|
41
|
+
end
|
42
|
+
|
43
|
+
def track_sample(id, max)
|
44
|
+
sampling_mutex.synchronize do
|
45
|
+
return false if sampling_counter[id] >= max
|
46
|
+
|
47
|
+
sampling_counter[id] += 1
|
48
|
+
true
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
attr_reader :all, :success, :failures, :sampling_counter, :sampling_mutex
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WSDirector
|
4
|
+
# Holds all results for all groups of clients
|
5
|
+
class ResultsHolder
|
6
|
+
def initialize
|
7
|
+
@groups = Concurrent::Map.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def success?
|
11
|
+
@groups.values.all?(&:success?)
|
12
|
+
end
|
13
|
+
|
14
|
+
def groups
|
15
|
+
@groups.values
|
16
|
+
end
|
17
|
+
|
18
|
+
def print_summary(printer: $stdout, colorize: false)
|
19
|
+
single_group = groups.size == 1
|
20
|
+
|
21
|
+
@groups.each do |group, result|
|
22
|
+
color = result.success? ? :green : :red
|
23
|
+
prefix = single_group ? "" : "Group #{group}: "
|
24
|
+
|
25
|
+
msg = "#{prefix}#{result.total_count} clients, #{result.failures_count} failures\n"
|
26
|
+
msg = msg.colorize(color) if colorize
|
27
|
+
|
28
|
+
printer.puts(msg)
|
29
|
+
|
30
|
+
unless result.success?
|
31
|
+
print_errors(result.errors, printer: printer, colorize: colorize)
|
32
|
+
printer.puts "\n"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def <<(result)
|
38
|
+
@groups[result.group] = result
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def print_errors(errors, printer:, colorize:)
|
44
|
+
printer.puts "\n"
|
45
|
+
|
46
|
+
errors.each.with_index do |error, i|
|
47
|
+
msg = "#{i + 1}) #{error}\n"
|
48
|
+
msg = msg.colorize(:red) if colorize
|
49
|
+
|
50
|
+
printer.puts msg
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
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:, connection_options:, locals:)
|
13
|
+
Runner.new(scenario, 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:)
|
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:, colorize:, global_holder:, result:, scale:, 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:,
|
131
|
+
name:,
|
132
|
+
ignore:,
|
133
|
+
protocol:,
|
134
|
+
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,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WSDirector
|
4
|
+
# Collect ws frame and dump them into a YAML file (or JSON)
|
5
|
+
class Snapshot
|
6
|
+
def initialize
|
7
|
+
@steps = []
|
8
|
+
@last_timestamp = nil
|
9
|
+
end
|
10
|
+
|
11
|
+
def <<(frame)
|
12
|
+
record_gap!
|
13
|
+
steps << {"send" => {"data" => frame}}
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_yml
|
17
|
+
::YAML.dump(steps)
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_json
|
21
|
+
steps.to_json
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
attr_reader :steps, :last_timestamp
|
27
|
+
|
28
|
+
def record_gap!
|
29
|
+
prev_timestamp = last_timestamp
|
30
|
+
@last_timestamp = Time.now
|
31
|
+
return unless prev_timestamp
|
32
|
+
|
33
|
+
delay = last_timestamp - prev_timestamp
|
34
|
+
steps << {"sleep" => {"time" => delay}}
|
35
|
+
end
|
36
|
+
end
|
37
|
+
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:, logger:, 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:, id:, 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
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WSDirector
|
4
|
+
module Utils # :nodoc:
|
5
|
+
MULTIPLIER_FORMAT = /^[-+*\/\\\d ]+$/
|
6
|
+
|
7
|
+
attr_reader :scale
|
8
|
+
|
9
|
+
def parse_multiplier(str)
|
10
|
+
prepared = str.to_s.gsub(":scale", scale.to_s)
|
11
|
+
raise WSDirector::Error, "Unknown multiplier format: #{str}" unless
|
12
|
+
MULTIPLIER_FORMAT.match?(prepared)
|
13
|
+
|
14
|
+
eval(prepared) # rubocop:disable Security/Eval
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/wsdirector.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent"
|
4
|
+
require "yaml"
|
5
|
+
require "json"
|
6
|
+
|
7
|
+
require "ruby-next"
|
8
|
+
|
9
|
+
require "ruby-next/language/setup"
|
10
|
+
RubyNext::Language.setup_gem_load_path(transpile: true)
|
11
|
+
|
12
|
+
require "wsdirector/version"
|
13
|
+
require "wsdirector/utils"
|
14
|
+
|
15
|
+
# Command line tool for testing websocket servers using scenarios.
|
16
|
+
module WSDirector
|
17
|
+
class Error < StandardError
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
require "wsdirector/runner"
|
22
|
+
require "wsdirector/snapshot"
|