wsdirector-cli 0.4.0 → 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.
data/bin/wsdirector DELETED
@@ -1,15 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "rubygems"
4
-
5
- $:.unshift(File.expand_path(__dir__ + "/../lib"))
6
-
7
- require "wsdirector/cli"
8
-
9
- begin
10
- WSDirector::CLI.new.tap { |cli| cli.run }
11
- rescue => e
12
- STDERR.puts e.message
13
- STDERR.puts e.backtrace.join("\n")
14
- exit 1
15
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "optparse"
4
-
5
- require "wsdirector"
6
- require "wsdirector/scenario_reader"
7
- require "wsdirector/runner"
8
-
9
- module WSDirector
10
- # Command line interface for WsDirector
11
- class CLI
12
- def initialize
13
- end
14
-
15
- def run
16
- parse_args!
17
-
18
- begin
19
- require "colorize" if WSDirector.config.colorize?
20
- rescue LoadError
21
- WSDirector.config.colorize = false
22
- warn "Install colorize to use colored output"
23
- end
24
-
25
- scenario = WSDirector::ScenarioReader.parse(
26
- WSDirector.config.scenario_path
27
- )
28
-
29
- if WSDirector::Runner.new(scenario).start
30
- exit 0
31
- else
32
- exit 1
33
- end
34
- rescue Error => e
35
- warn e.message
36
- exit 1
37
- end
38
-
39
- private
40
-
41
- def parse_args!
42
- # rubocop: disable Metrics/LineLength
43
- parser = OptionParser.new do |opts|
44
- opts.banner = "Usage: wsdirector scenario_path ws_url [options]"
45
-
46
- opts.on("-s SCALE", "--scale=SCALE", Integer, "Scale factor") do |v|
47
- WSDirector.config.scale = v
48
- end
49
-
50
- opts.on("-t TIMEOUT", "--timeout=TIMEOUT", Integer, "Synchronization (wait_all) timeout") do |v|
51
- WSDirector.config.sync_timeout = v
52
- end
53
-
54
- opts.on("-c", "--[no-]color", "Colorize output") do |v|
55
- WSDirector.config.colorize = v
56
- end
57
-
58
- opts.on("-v", "--version", "Print versin") do
59
- $stdout.puts WSDirector::VERSION
60
- exit 0
61
- end
62
- end
63
- # rubocop: enable Metrics/LineLength
64
-
65
- parser.parse!
66
-
67
- WSDirector.config.scenario_path = ARGV[0]
68
- WSDirector.config.ws_url = ARGV[1]
69
-
70
- raise(Error, "Scenario path is missing") if WSDirector.config.scenario_path.nil?
71
-
72
- raise(Error, "File doesn't exist #{WSDirector.config.scenario_path}") unless
73
- File.file?(WSDirector.config.scenario_path)
74
-
75
- raise(Error, "Websocket server url is missing") if WSDirector.config.ws_url.nil?
76
- end
77
- end
78
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "websocket-client-simple"
4
- require "securerandom"
5
-
6
- module WSDirector
7
- # WebSocket client
8
- class Client
9
- WAIT_WHEN_EXPECTING_EVENT = 5
10
-
11
- attr_reader :ws, :id
12
-
13
- # Create new WebSocket client and connect to WSDirector
14
- # ws URL.
15
- #
16
- # Optionally provide an ignore pattern (to ignore incoming message,
17
- # for example, pings)
18
- def initialize(ignore: nil)
19
- @ignore = ignore
20
- has_messages = @has_messages = Concurrent::Semaphore.new(0)
21
- messages = @messages = Queue.new
22
- path = WSDirector.config.ws_url
23
- open = Concurrent::Promise.new
24
- client = self
25
-
26
- @id = SecureRandom.hex(6)
27
- @ws = WebSocket::Client::Simple.connect(path) do |ws|
28
- ws.on(:open) do |_event|
29
- open.set(true)
30
- end
31
-
32
- ws.on :message do |msg|
33
- data = msg.data
34
- next if data.empty?
35
- next if client.ignored?(data)
36
- messages << data
37
- has_messages.release
38
- end
39
-
40
- ws.on :error do |e|
41
- messages << Error.new("WebSocket Error #{e.inspect} #{e.backtrace}")
42
- end
43
- end
44
-
45
- open.wait!(WAIT_WHEN_EXPECTING_EVENT)
46
- rescue Errno::ECONNREFUSED
47
- raise Error, "Failed to connect to #{path}"
48
- end
49
-
50
- def receive(timeout = WAIT_WHEN_EXPECTING_EVENT)
51
- @has_messages.try_acquire(1, timeout)
52
- msg = @messages.pop(true)
53
- raise msg if msg.is_a?(Exception)
54
- msg
55
- end
56
-
57
- def send(msg)
58
- @ws.send(msg)
59
- end
60
-
61
- def ignored?(msg)
62
- return false unless @ignore
63
- @ignore.any? { |pattern| msg =~ pattern }
64
- end
65
- end
66
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- # Acts as a re-usable global barrier for a fixed number of clients.
5
- # Barrier is reset if sucessfully passed in time.
6
- class ClientsHolder
7
- def initialize(count)
8
- @barrier = Concurrent::CyclicBarrier.new(count)
9
- end
10
-
11
- def wait_all
12
- result = barrier.wait(WSDirector.config.sync_timeout)
13
- raise Error, "Timeout (#{WSDirector.config.sync_timeout}s) exceeded for #wait_all" unless
14
- result
15
- barrier.reset
16
- result
17
- end
18
-
19
- private
20
-
21
- attr_reader :barrier
22
- end
23
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- # WSDirector configuration
5
- class Configuration
6
- attr_accessor :ws_url, :scenario_path, :colorize, :scale,
7
- :sync_timeout
8
-
9
- def initialize
10
- reset!
11
- end
12
-
13
- def colorize?
14
- colorize == true
15
- end
16
-
17
- # Restore to defaults
18
- def reset!
19
- @scale = 1
20
- @colorize = false
21
- @sync_timeout = 5
22
- end
23
- end
24
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- module Ext
5
- # Extend Object through refinements
6
- module DeepDup
7
- refine ::Hash do
8
- # Based on ActiveSupport http://api.rubyonrails.org/classes/Hash.html#method-i-deep_dup
9
- def deep_dup
10
- each_with_object(dup) do |(key, value), hash|
11
- hash[key] = if value.is_a?(::Hash) || value.is_a?(::Array)
12
- value.deep_dup
13
- else
14
- value
15
- end
16
- end
17
- end
18
- end
19
-
20
- refine ::Array do
21
- # From ActiveSupport http://api.rubyonrails.org/classes/Array.html#method-i-deep_dup
22
- def deep_dup
23
- map do |value|
24
- if value.is_a?(::Hash) || value.is_a?(::Array)
25
- value.deep_dup
26
- else
27
- value
28
- end
29
- end
30
- end
31
- end
32
- end
33
- end
34
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- # Print messages (optionally colorized) to STDOUT
5
- class Printer
6
- def self.out(message, color = nil)
7
- message = message.colorize(color) if WSDirector.config.colorize? && color
8
- $stdout.puts(message)
9
- end
10
- end
11
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module WSDirector
4
- module Protocols
5
- # ActionCable protocol
6
- class ActionCable < Base
7
- WELCOME_MSG = {type: "welcome"}.to_json
8
- PING_IGNORE = /['"]type['"]:\s*['"]ping['"]/
9
-
10
- # Add ping ignore and make sure that we receive Welcome message
11
- def init_client(**options)
12
- options[:ignore] ||= [PING_IGNORE]
13
-
14
- super(**options)
15
-
16
- receive("data" => WELCOME_MSG)
17
- end
18
-
19
- def subscribe(step)
20
- identifier = extract_identifier(step)
21
-
22
- client.send({command: "subscribe", identifier: identifier}.to_json)
23
-
24
- begin
25
- receive(
26
- "data" => {"type" => "confirm_subscription", "identifier" => identifier}
27
- )
28
- rescue UnmatchedExpectationError => e
29
- raise unless /reject_subscription/.match?(e.message)
30
- raise UnmatchedExpectationError, "Subscription rejected to #{identifier}"
31
- end
32
- end
33
-
34
- def perform(step)
35
- identifier = extract_identifier(step)
36
- action = step.delete("action")
37
-
38
- raise Error, "Action is missing" unless action
39
-
40
- data = step.fetch("data", {}).merge(action: action).to_json
41
-
42
- client.send({command: "message", data: data, identifier: identifier}.to_json)
43
- end
44
-
45
- def receive(step)
46
- return super unless step.key?("channel")
47
-
48
- identifier = extract_identifier(step)
49
- message = step.fetch("data", {})
50
- super("data" => {"identifier" => identifier, "message" => message})
51
- end
52
-
53
- def receive_all(step)
54
- messages = step["messages"]
55
-
56
- return super if messages.nil? || messages.empty?
57
-
58
- messages.each do |msg|
59
- next unless msg.key?("channel")
60
- identifier = extract_identifier(msg)
61
- msg["data"] = {"identifier" => identifier, "message" => msg["data"]}
62
- end
63
-
64
- super
65
- end
66
-
67
- private
68
-
69
- def extract_identifier(step)
70
- channel = step.delete("channel")
71
- step.fetch("params", {}).merge(channel: channel).to_json
72
- end
73
- end
74
- end
75
- end
@@ -1,135 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module WSDirector
6
- module Protocols
7
- # Base protocol describes basic actions
8
- class Base
9
- include WSDirector::Utils
10
-
11
- def initialize(task)
12
- @task = task
13
- end
14
-
15
- def init_client(**options)
16
- @client = build_client(**options)
17
- end
18
-
19
- def handle_step(step)
20
- type = step.delete("type")
21
- raise Error, "Unknown step: #{type}" unless respond_to?(type)
22
-
23
- return unless task.sampled?(step)
24
-
25
- public_send(type, step)
26
- end
27
-
28
- # Sleeps for a specified number of seconds.
29
- #
30
- # If "shift" is provided than the initial value is
31
- # shifted by random number from (-shift, shift).
32
- #
33
- # Set "debug" to true to print the delay time.
34
- def sleep(step)
35
- delay = step.fetch("time").to_f
36
- shift = step.fetch("shift", 0).to_f
37
-
38
- delay = delay - shift * rand + shift * rand
39
-
40
- print("Sleep for #{delay}s") if step.fetch("debug", false)
41
-
42
- Kernel.sleep delay if delay > 0
43
- end
44
-
45
- # Prints provided message
46
- def debug(step)
47
- print(step.fetch("message"))
48
- end
49
-
50
- def receive(step)
51
- expected = step.fetch("data")
52
- received = client.receive
53
- raise UnmatchedExpectationError, prepare_receive_error(expected, received) unless
54
- receive_matches?(expected, received)
55
- rescue ThreadError
56
- raise NoMessageError, "Expected to receive #{expected} but nothing has been received"
57
- end
58
-
59
- # rubocop: disable Metrics/CyclomaticComplexity
60
- def receive_all(step)
61
- messages = step.delete("messages")
62
- raise ArgumentError, "Messages array must be specified" if
63
- messages.nil? || messages.empty?
64
-
65
- expected =
66
- Hash[messages.map do |msg|
67
- multiplier = parse_multiplier(msg.delete("multiplier") || "1")
68
- [msg["data"], multiplier]
69
- end]
70
-
71
- total_expected = expected.values.sum
72
- total_received = 0
73
-
74
- total_expected.times do
75
- received = client.receive
76
-
77
- total_received += 1
78
-
79
- match = expected.find { |k, _| receive_matches?(k, received) }
80
-
81
- raise UnexpectedMessageError, "Unexpected message received: #{received}" if
82
- match.nil?
83
-
84
- expected[match.first] -= 1
85
- expected.delete(match.first) if expected[match.first].zero?
86
- end
87
- rescue ThreadError
88
- raise NoMessageError,
89
- "Expected to receive #{total_expected} messages " \
90
- "but received only #{total_received}"
91
- end
92
- # rubocop: enable Metrics/CyclomaticComplexity
93
-
94
- def send(step)
95
- data = step.fetch("data")
96
- data = JSON.generate(data) if data.is_a?(Hash)
97
- client.send(data)
98
- end
99
-
100
- def wait_all(_step)
101
- task.global_holder.wait_all
102
- end
103
-
104
- def to_proc
105
- proc { |step| handle_step(step) }
106
- end
107
-
108
- private
109
-
110
- attr_reader :client, :task
111
-
112
- def build_client(**options)
113
- Client.new(**options)
114
- end
115
-
116
- def receive_matches?(expected, received)
117
- received = JSON.parse(received) if expected.is_a?(Hash)
118
-
119
- received == expected
120
- end
121
-
122
- def prepare_receive_error(expected, received)
123
- <<~MSG
124
- Action failed: #receive
125
- -- expected: #{expected}
126
- ++ got: #{received}
127
- MSG
128
- end
129
-
130
- def print(msg)
131
- $stdout.puts "DEBUG #{Time.now.iso8601} client=#{client.id} #{msg}\n"
132
- end
133
- end
134
- end
135
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "wsdirector/protocols/base"
4
- require "wsdirector/protocols/action_cable"
5
-
6
- module WSDirector
7
- ID2CLASS = {
8
- "base" => "Base",
9
- "action_cable" => "ActionCable"
10
- }.freeze
11
-
12
- module Protocols # :nodoc:
13
- # Raised when received not expected message
14
- class UnmatchedExpectationError < WSDirector::Error; end
15
- # Raised when received message is unexpected
16
- class UnexpectedMessageError < WSDirector::Error; end
17
- # Raised when nothing has been received
18
- class NoMessageError < WSDirector::Error; end
19
-
20
- class << self
21
- def get(id)
22
- raise Error, "Unknown protocol: #{id}" unless ID2CLASS.key?(id)
23
- class_name = ID2CLASS.fetch(id)
24
- const_get(class_name)
25
- end
26
- end
27
- end
28
- end
@@ -1,56 +0,0 @@
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
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "wsdirector/printer"
4
-
5
- module WSDirector
6
- # Holds all results for all groups of clients
7
- class ResultsHolder
8
- def initialize
9
- @groups = Concurrent::Map.new
10
- end
11
-
12
- def success?
13
- @groups.values.all?(&:success?)
14
- end
15
-
16
- def print_summary
17
- single_group = groups.size == 1
18
- groups.each do |group, result|
19
- color = result.success? ? :green : :red
20
- prefix = single_group ? "" : "Group #{group}: "
21
- Printer.out(
22
- "#{prefix}#{result.total_count} clients, #{result.failures_count} failures\n",
23
- color
24
- )
25
-
26
- unless result.success?
27
- print_errors(result.errors)
28
- Printer.out "\n"
29
- end
30
- end
31
- end
32
-
33
- def <<(result)
34
- groups[result.group] = result
35
- end
36
-
37
- private
38
-
39
- attr_reader :groups
40
-
41
- def print_errors(errors)
42
- Printer.out "\n"
43
- errors.each.with_index do |error, i|
44
- Printer.out "#{i + 1}) #{error}\n", :red
45
- end
46
- end
47
- end
48
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "wsdirector/clients_holder"
4
- require "wsdirector/results_holder"
5
- require "wsdirector/result"
6
- require "wsdirector/task"
7
- require "wsdirector/ext/deep_dup"
8
-
9
- module WSDirector
10
- # Initiates all clients as a separate tasks (=threads)
11
- class Runner
12
- using WSDirector::Ext::DeepDup
13
-
14
- def initialize(scenario)
15
- @scenario = scenario
16
- @total_count = scenario["total"]
17
- @global_holder = ClientsHolder.new(total_count)
18
- @results_holder = ResultsHolder.new
19
- end
20
-
21
- def start
22
- Thread.abort_on_exception = true
23
-
24
- tasks = scenario["clients"].flat_map do |client|
25
- result = Result.new(client.fetch("name"))
26
- results_holder << result
27
-
28
- Array.new(client.fetch("multiplier")) do
29
- Thread.new do
30
- Task.new(client.deep_dup, global_holder: global_holder, result: result)
31
- .run
32
- end
33
- end
34
- end
35
-
36
- tasks.each(&:join)
37
- results_holder.print_summary
38
- results_holder.success?
39
- end
40
-
41
- private
42
-
43
- attr_reader :scenario, :total_count, :global_holder, :results_holder
44
- end
45
- end