wsdirector-cli 0.2.1
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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +143 -0
- data/bin/wsdirector +15 -0
- data/lib/wsdirector.rb +22 -0
- data/lib/wsdirector/cli.rb +77 -0
- data/lib/wsdirector/client.rb +64 -0
- data/lib/wsdirector/clients_holder.rb +23 -0
- data/lib/wsdirector/configuration.rb +24 -0
- data/lib/wsdirector/ext/deep_dup.rb +34 -0
- data/lib/wsdirector/printer.rb +11 -0
- data/lib/wsdirector/protocols.rb +26 -0
- data/lib/wsdirector/protocols/action_cable.rb +61 -0
- data/lib/wsdirector/protocols/base.rb +67 -0
- data/lib/wsdirector/result.rb +44 -0
- data/lib/wsdirector/results_holder.rb +48 -0
- data/lib/wsdirector/runner.rb +45 -0
- data/lib/wsdirector/scenario_reader.rb +80 -0
- data/lib/wsdirector/task.rb +39 -0
- data/lib/wsdirector/version.rb +5 -0
- metadata +237 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 12469915784ca778073f50ab6b6157c73c014bee
|
4
|
+
data.tar.gz: '09f32425b8005d8cade46678259222286f4eff8b'
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1985ee2404b98a54e9a63f40b35db59e139738f9d4d3f3eeb79bc451533f97f3a85c0f2abebdb5aa473e8a7f836a4597bdf058d6bd8cf8c016d85226545606b4
|
7
|
+
data.tar.gz: 55994d15c789a8f8c3bad3b2847a9d274a7ed581081e5928c071c6daeb6f4a1e2d225e844e7499364bf051982dca87cd28e4e9f4f563deb52c22bbe9087e1be6
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 palkan
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
[](https://rubygems.org/gems/wsdirector-cli) [](https://circleci.com/gh/palkan/wsdirector)
|
2
|
+
|
3
|
+
# WebSocket Director
|
4
|
+
|
5
|
+
Command line tool for testing websocket servers using scenarios.
|
6
|
+
|
7
|
+
Suitable for testing any websocket server implementation, like [Action Cable](https://github.com/rails/rails/tree/master/actioncable), [Websocket Eventmachine Server](https://github.com/imanel/websocket-eventmachine-server), [Litecable](https://github.com/palkan/litecable) and so on.
|
8
|
+
|
9
|
+
<a href="https://evilmartians.com/">
|
10
|
+
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
```bash
|
15
|
+
$ gem install wsdirector-cli
|
16
|
+
```
|
17
|
+
|
18
|
+
## Usage
|
19
|
+
|
20
|
+
Create YAML file with simle testing script:
|
21
|
+
|
22
|
+
```yml
|
23
|
+
# script.yml
|
24
|
+
- receive: "Welcome" # expect to receive message
|
25
|
+
- send:
|
26
|
+
data: "send message" # send message, all messages in data will be parse to json
|
27
|
+
- receive:
|
28
|
+
data: "receive message" # expect to receive json message
|
29
|
+
```
|
30
|
+
|
31
|
+
and run it with this command:
|
32
|
+
|
33
|
+
```bash
|
34
|
+
wsdirector script.yml ws://websocket.server:9876/ws
|
35
|
+
|
36
|
+
#=> 1 clients, 0 failures
|
37
|
+
```
|
38
|
+
|
39
|
+
You can create more complex scenarios with multiple client groups:
|
40
|
+
|
41
|
+
```yml
|
42
|
+
# script.yml
|
43
|
+
- client: # first clients group
|
44
|
+
name: "publisher" # optional group name
|
45
|
+
multiplier: ":scale" # :scale take number from -s param, and run :scale number of clients in this group
|
46
|
+
actions: #
|
47
|
+
- receive:
|
48
|
+
data: "Welcome"
|
49
|
+
- wait_all # makes all clients in all groups wait untill every client get this point (global barrier)
|
50
|
+
- send:
|
51
|
+
data: "test message"
|
52
|
+
- client:
|
53
|
+
name: "listeners"
|
54
|
+
multiplier: ":scale * 2"
|
55
|
+
actions:
|
56
|
+
- receive:
|
57
|
+
data: "Welcome"
|
58
|
+
- wait_all
|
59
|
+
- receive:
|
60
|
+
multiplier: ":scale" # you can use multiplier with any action
|
61
|
+
data: "test message"
|
62
|
+
```
|
63
|
+
|
64
|
+
Run with scale factor:
|
65
|
+
|
66
|
+
|
67
|
+
```bash
|
68
|
+
wsdirector script.yml ws://websocket.server:9876 -s 10
|
69
|
+
|
70
|
+
#=> Group publisher: 10 clients, 0 failures
|
71
|
+
#=> Group listeners: 20 clients, 0 failures
|
72
|
+
```
|
73
|
+
|
74
|
+
The simpliest scenario is just checking that socket is succesfully connected:
|
75
|
+
|
76
|
+
```yml
|
77
|
+
- client:
|
78
|
+
name: connection check
|
79
|
+
# no actions
|
80
|
+
```
|
81
|
+
|
82
|
+
### Protocols
|
83
|
+
|
84
|
+
WSDirector uses protocols to handle different actions.
|
85
|
+
Currently, we support "base" protocol (with `send`, `receive`, `wait_all` actions) and "action_cable" protocol, which extends "base" with `subscribe` and `perform` actions.
|
86
|
+
|
87
|
+
#### ActionCable Example
|
88
|
+
|
89
|
+
Channel code:
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
class ChatChannel < ApplicationCable::Channel
|
93
|
+
def subscribed
|
94
|
+
stream_from "chat_test"
|
95
|
+
end
|
96
|
+
|
97
|
+
def echo(data)
|
98
|
+
transmit data
|
99
|
+
end
|
100
|
+
|
101
|
+
def broadcast(data)
|
102
|
+
ActionCable.server.broadcast "chat_test", data
|
103
|
+
end
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
Scenario:
|
108
|
+
|
109
|
+
```yml
|
110
|
+
- client:
|
111
|
+
multiplier: ":scale"
|
112
|
+
name: "publisher"
|
113
|
+
protocol: "action_cable"
|
114
|
+
actions:
|
115
|
+
- subscribe:
|
116
|
+
channel: "ChatChannel"
|
117
|
+
- wait_all
|
118
|
+
- perform:
|
119
|
+
channel: "ChatChannel"
|
120
|
+
action: "broadcast"
|
121
|
+
data:
|
122
|
+
text: "hello"
|
123
|
+
- client:
|
124
|
+
name: "listener"
|
125
|
+
protocol: "action_cable"
|
126
|
+
actions:
|
127
|
+
- subscribe:
|
128
|
+
channel: "ChatChannel"
|
129
|
+
- wait_all
|
130
|
+
- receive:
|
131
|
+
channel: "ChatChannel"
|
132
|
+
data:
|
133
|
+
text: "hello"
|
134
|
+
```
|
135
|
+
|
136
|
+
## Contributing
|
137
|
+
|
138
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/wsdirector.
|
139
|
+
|
140
|
+
|
141
|
+
## License
|
142
|
+
|
143
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/bin/wsdirector
ADDED
@@ -0,0 +1,15 @@
|
|
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
|
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 "wsdirector/version"
|
8
|
+
require "wsdirector/configuration"
|
9
|
+
|
10
|
+
# Command line tool for testing websocket servers using scenarios.
|
11
|
+
module WSDirector
|
12
|
+
class Error < StandardError
|
13
|
+
end
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def config
|
17
|
+
@config ||= Configuration.new
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
require "wsdirector/cli"
|
@@ -0,0 +1,77 @@
|
|
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; end
|
13
|
+
|
14
|
+
def run
|
15
|
+
parse_args!
|
16
|
+
|
17
|
+
begin
|
18
|
+
require "colorize" if WSDirector.config.colorize?
|
19
|
+
rescue LoadError
|
20
|
+
WSDirector.config.colorize = false
|
21
|
+
warn "Install colorize to use colored output"
|
22
|
+
end
|
23
|
+
|
24
|
+
scenario = WSDirector::ScenarioReader.parse(
|
25
|
+
WSDirector.config.scenario_path
|
26
|
+
)
|
27
|
+
|
28
|
+
if WSDirector::Runner.new(scenario).start
|
29
|
+
exit 0
|
30
|
+
else
|
31
|
+
exit 1
|
32
|
+
end
|
33
|
+
rescue Error => e
|
34
|
+
STDERR.puts e.message
|
35
|
+
exit 1
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def parse_args!
|
41
|
+
# rubocop: disable Metrics/LineLength
|
42
|
+
parser = OptionParser.new do |opts|
|
43
|
+
opts.banner = "Usage: wsdirector scenario_path ws_url [options]"
|
44
|
+
|
45
|
+
opts.on("-s SCALE", "--scale=SCALE", Integer, "Scale factor") do |v|
|
46
|
+
WSDirector.config.scale = v
|
47
|
+
end
|
48
|
+
|
49
|
+
opts.on("-t TIMEOUT", "--timeout=TIMEOUT", Integer, "Synchronization (wait_all) timeout") do |v|
|
50
|
+
WSDirector.config.sync_timeout = v
|
51
|
+
end
|
52
|
+
|
53
|
+
opts.on("-c", "--[no-]color", "Colorize output") do |v|
|
54
|
+
WSDirector.config.colorize = v
|
55
|
+
end
|
56
|
+
|
57
|
+
opts.on("-v", "--version", "Print versin") do
|
58
|
+
STDOUT.puts WSDirector::VERSION
|
59
|
+
exit 0
|
60
|
+
end
|
61
|
+
end
|
62
|
+
# rubocop: enable Metrics/LineLength
|
63
|
+
|
64
|
+
parser.parse!
|
65
|
+
|
66
|
+
WSDirector.config.scenario_path = ARGV[0]
|
67
|
+
WSDirector.config.ws_url = ARGV[1]
|
68
|
+
|
69
|
+
raise(Error, "Scenario path is missing") if WSDirector.config.scenario_path.nil?
|
70
|
+
|
71
|
+
raise(Error, "File doesn't exist #{WSDirector.config.scenario_path}") unless
|
72
|
+
File.file?(WSDirector.config.scenario_path)
|
73
|
+
|
74
|
+
raise(Error, "Websocket server url is missing") if WSDirector.config.ws_url.nil?
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "websocket-client-simple"
|
4
|
+
|
5
|
+
module WSDirector
|
6
|
+
# WebSocket client
|
7
|
+
class Client
|
8
|
+
WAIT_WHEN_EXPECTING_EVENT = 5
|
9
|
+
|
10
|
+
attr_reader :ws
|
11
|
+
|
12
|
+
# Create new WebSocket client and connect to WSDirector
|
13
|
+
# ws URL.
|
14
|
+
#
|
15
|
+
# Optionally provide an ignore pattern (to ignore incoming message,
|
16
|
+
# for example, pings)
|
17
|
+
def initialize(ignore: nil)
|
18
|
+
@ignore = ignore
|
19
|
+
has_messages = @has_messages = Concurrent::Semaphore.new(0)
|
20
|
+
messages = @messages = Queue.new
|
21
|
+
path = WSDirector.config.ws_url
|
22
|
+
open = Concurrent::Promise.new
|
23
|
+
client = self
|
24
|
+
|
25
|
+
@ws = WebSocket::Client::Simple.connect(path) do |ws|
|
26
|
+
ws.on(:open) do |_event|
|
27
|
+
open.set(true)
|
28
|
+
end
|
29
|
+
|
30
|
+
ws.on :message do |msg|
|
31
|
+
data = msg.data
|
32
|
+
next if data.empty?
|
33
|
+
next if client.ignored?(data)
|
34
|
+
messages << data
|
35
|
+
has_messages.release
|
36
|
+
end
|
37
|
+
|
38
|
+
ws.on :error do |e|
|
39
|
+
messages << Error.new("WebSocket Error #{e.inspect} #{e.backtrace}")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
open.wait!(WAIT_WHEN_EXPECTING_EVENT)
|
44
|
+
rescue Errno::ECONNREFUSED
|
45
|
+
raise Error, "Failed to connect to #{path}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def receive(timeout = WAIT_WHEN_EXPECTING_EVENT)
|
49
|
+
@has_messages.try_acquire(1, timeout)
|
50
|
+
msg = @messages.pop(true)
|
51
|
+
raise msg if msg.is_a?(Exception)
|
52
|
+
msg
|
53
|
+
end
|
54
|
+
|
55
|
+
def send(msg)
|
56
|
+
@ws.send(msg)
|
57
|
+
end
|
58
|
+
|
59
|
+
def ignored?(msg)
|
60
|
+
return false unless @ignore
|
61
|
+
@ignore.any? { |pattern| msg =~ pattern }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,23 @@
|
|
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
|
@@ -0,0 +1,24 @@
|
|
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
|
@@ -0,0 +1,34 @@
|
|
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
|
@@ -0,0 +1,11 @@
|
|
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
|
@@ -0,0 +1,26 @@
|
|
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 nothing has been received
|
16
|
+
class NoMessageError < WSDirector::Error; end
|
17
|
+
|
18
|
+
class << self
|
19
|
+
def get(id)
|
20
|
+
raise Error, "Unknown protocol: #{id}" unless ID2CLASS.key?(id)
|
21
|
+
class_name = ID2CLASS.fetch(id)
|
22
|
+
const_get(class_name)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,61 @@
|
|
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 e.message =~ /reject_subscription/
|
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
|
+
private
|
54
|
+
|
55
|
+
def extract_identifier(step)
|
56
|
+
channel = step.delete("channel")
|
57
|
+
step.fetch("params", {}).merge(channel: channel).to_json
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WSDirector
|
4
|
+
module Protocols
|
5
|
+
# Base protocol describes basic actions
|
6
|
+
class Base
|
7
|
+
def initialize(task)
|
8
|
+
@task = task
|
9
|
+
end
|
10
|
+
|
11
|
+
def init_client(**options)
|
12
|
+
@client = build_client(**options)
|
13
|
+
end
|
14
|
+
|
15
|
+
def handle_step(step)
|
16
|
+
type = step.delete("type")
|
17
|
+
raise Error, "Unknown step: #{type}" unless respond_to?(type)
|
18
|
+
public_send(type, step)
|
19
|
+
end
|
20
|
+
|
21
|
+
def receive(step)
|
22
|
+
expected = step.fetch("data")
|
23
|
+
received = client.receive
|
24
|
+
receive_matches?(expected, received)
|
25
|
+
rescue ThreadError
|
26
|
+
raise NoMessageError, "Expected to receive #{expected} but nothing has been received"
|
27
|
+
end
|
28
|
+
|
29
|
+
def send(step)
|
30
|
+
data = step.fetch("data")
|
31
|
+
data = JSON.generate(data) if data.is_a?(Hash)
|
32
|
+
client.send(data)
|
33
|
+
end
|
34
|
+
|
35
|
+
def wait_all(_step)
|
36
|
+
task.global_holder.wait_all
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_proc
|
40
|
+
proc { |step| handle_step(step) }
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
attr_reader :client, :task
|
46
|
+
|
47
|
+
def build_client(**options)
|
48
|
+
Client.new(**options)
|
49
|
+
end
|
50
|
+
|
51
|
+
def receive_matches?(expected, received)
|
52
|
+
received = JSON.parse(received) if expected.is_a?(Hash)
|
53
|
+
|
54
|
+
raise UnmatchedExpectationError, prepare_receive_error(expected, received) if
|
55
|
+
received != expected
|
56
|
+
end
|
57
|
+
|
58
|
+
def prepare_receive_error(expected, received)
|
59
|
+
<<~MSG
|
60
|
+
Action failed: #receive
|
61
|
+
-- expected: #{expected}
|
62
|
+
++ got: #{received}
|
63
|
+
MSG
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,44 @@
|
|
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
|
+
end
|
15
|
+
|
16
|
+
# Called when client successfully finished it's work
|
17
|
+
def succeed
|
18
|
+
all.increment
|
19
|
+
end
|
20
|
+
|
21
|
+
# Called when client failed
|
22
|
+
def failed(error_message)
|
23
|
+
errors << error_message
|
24
|
+
all.increment
|
25
|
+
failures.increment
|
26
|
+
end
|
27
|
+
|
28
|
+
def success?
|
29
|
+
failures.value.zero?
|
30
|
+
end
|
31
|
+
|
32
|
+
def total_count
|
33
|
+
all.value
|
34
|
+
end
|
35
|
+
|
36
|
+
def failures_count
|
37
|
+
failures.value
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
attr_reader :all, :success, :failures
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,48 @@
|
|
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
|
@@ -0,0 +1,45 @@
|
|
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
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WSDirector
|
4
|
+
# Read and parse YAML scenario
|
5
|
+
class ScenarioReader
|
6
|
+
MULTIPLIER_FORMAT = /^[-+*\\\d ]+$/
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def parse(file_path)
|
10
|
+
contents = YAML.load_file(file_path)
|
11
|
+
|
12
|
+
if contents.first.key?("client")
|
13
|
+
parse_multiple_scenarios(contents)
|
14
|
+
else
|
15
|
+
{ "total" => 1, "clients" => [parse_simple_scenario(contents)] }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def handle_steps(steps)
|
22
|
+
steps.flat_map do |step|
|
23
|
+
if step.is_a?(Hash)
|
24
|
+
type, data = step.to_a.first
|
25
|
+
multiplier = parse_multiplier(data.delete("multiplier") || "1")
|
26
|
+
Array.new(multiplier) { { "type" => type }.merge(data) }
|
27
|
+
else
|
28
|
+
{ "type" => step }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def parse_simple_scenario(
|
34
|
+
steps,
|
35
|
+
multiplier: 1, name: "default", ignore: nil, protocol: "base"
|
36
|
+
)
|
37
|
+
{
|
38
|
+
"multiplier" => multiplier,
|
39
|
+
"steps" => handle_steps(steps),
|
40
|
+
"name" => name,
|
41
|
+
"ignore" => ignore,
|
42
|
+
"protocol" => protocol
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def parse_multiple_scenarios(definitions)
|
47
|
+
total_count = 0
|
48
|
+
clients = definitions.map.with_index do |client, i|
|
49
|
+
_, client = client.to_a.first
|
50
|
+
multiplier = parse_multiplier(client.delete("multiplier") || "1")
|
51
|
+
name = client.delete("name") || (i + 1).to_s
|
52
|
+
total_count += multiplier
|
53
|
+
ignore = parse_ingore(client.fetch("ignore", nil))
|
54
|
+
parse_simple_scenario(
|
55
|
+
client.fetch("actions", []),
|
56
|
+
multiplier: multiplier,
|
57
|
+
name: name,
|
58
|
+
ignore: ignore,
|
59
|
+
protocol: client.fetch("protocol", "base")
|
60
|
+
)
|
61
|
+
end
|
62
|
+
{ "total" => total_count, "clients" => clients }
|
63
|
+
end
|
64
|
+
|
65
|
+
def parse_multiplier(str)
|
66
|
+
prepared = str.to_s.gsub(":scale", WSDirector.config.scale.to_s)
|
67
|
+
raise WSDirector::Error, "Unknown multiplier format: #{str}" unless
|
68
|
+
prepared =~ MULTIPLIER_FORMAT
|
69
|
+
|
70
|
+
eval(prepared) # rubocop:disable Security/Eval
|
71
|
+
end
|
72
|
+
|
73
|
+
def parse_ingore(str)
|
74
|
+
return unless str
|
75
|
+
|
76
|
+
Array(str)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,39 @@
|
|
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, global_holder:, result:)
|
12
|
+
@ignore = config.fetch("ignore")
|
13
|
+
@steps = config.fetch("steps")
|
14
|
+
@global_holder = global_holder
|
15
|
+
@result = result
|
16
|
+
|
17
|
+
protocol_class = Protocols.get(config.fetch("protocol", "base"))
|
18
|
+
@protocol = protocol_class.new(self)
|
19
|
+
end
|
20
|
+
|
21
|
+
def run
|
22
|
+
connect!
|
23
|
+
|
24
|
+
steps.each(&protocol)
|
25
|
+
|
26
|
+
result.succeed
|
27
|
+
rescue Error => e
|
28
|
+
result.failed(e.message)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
attr_reader :steps, :result, :protocol
|
34
|
+
|
35
|
+
def connect!
|
36
|
+
protocol.init_client(ignore: @ignore)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
metadata
ADDED
@@ -0,0 +1,237 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: wsdirector-cli
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Kirill Arkhipov
|
8
|
+
- Grandman
|
9
|
+
- palkan
|
10
|
+
autorequire:
|
11
|
+
bindir: bin
|
12
|
+
cert_chain: []
|
13
|
+
date: 2017-11-05 00:00:00.000000000 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: websocket-client-simple
|
17
|
+
requirement: !ruby/object:Gem::Requirement
|
18
|
+
requirements:
|
19
|
+
- - "~>"
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0.3'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - "~>"
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: '0.3'
|
29
|
+
- !ruby/object:Gem::Dependency
|
30
|
+
name: concurrent-ruby
|
31
|
+
requirement: !ruby/object:Gem::Requirement
|
32
|
+
requirements:
|
33
|
+
- - "~>"
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: 1.0.5
|
36
|
+
type: :runtime
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - "~>"
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: 1.0.5
|
43
|
+
- !ruby/object:Gem::Dependency
|
44
|
+
name: colorize
|
45
|
+
requirement: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '0'
|
50
|
+
type: :development
|
51
|
+
prerelease: false
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
- !ruby/object:Gem::Dependency
|
58
|
+
name: websocket-eventmachine-server
|
59
|
+
requirement: !ruby/object:Gem::Requirement
|
60
|
+
requirements:
|
61
|
+
- - "~>"
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: 1.0.1
|
64
|
+
type: :development
|
65
|
+
prerelease: false
|
66
|
+
version_requirements: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - "~>"
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: 1.0.1
|
71
|
+
- !ruby/object:Gem::Dependency
|
72
|
+
name: rack
|
73
|
+
requirement: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - "~>"
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '2.0'
|
78
|
+
type: :development
|
79
|
+
prerelease: false
|
80
|
+
version_requirements: !ruby/object:Gem::Requirement
|
81
|
+
requirements:
|
82
|
+
- - "~>"
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '2.0'
|
85
|
+
- !ruby/object:Gem::Dependency
|
86
|
+
name: litecable
|
87
|
+
requirement: !ruby/object:Gem::Requirement
|
88
|
+
requirements:
|
89
|
+
- - "~>"
|
90
|
+
- !ruby/object:Gem::Version
|
91
|
+
version: '0.5'
|
92
|
+
type: :development
|
93
|
+
prerelease: false
|
94
|
+
version_requirements: !ruby/object:Gem::Requirement
|
95
|
+
requirements:
|
96
|
+
- - "~>"
|
97
|
+
- !ruby/object:Gem::Version
|
98
|
+
version: '0.5'
|
99
|
+
- !ruby/object:Gem::Dependency
|
100
|
+
name: puma
|
101
|
+
requirement: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - "~>"
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: '3.6'
|
106
|
+
type: :development
|
107
|
+
prerelease: false
|
108
|
+
version_requirements: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - "~>"
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '3.6'
|
113
|
+
- !ruby/object:Gem::Dependency
|
114
|
+
name: bundler
|
115
|
+
requirement: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - "~>"
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '1.13'
|
120
|
+
type: :development
|
121
|
+
prerelease: false
|
122
|
+
version_requirements: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - "~>"
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '1.13'
|
127
|
+
- !ruby/object:Gem::Dependency
|
128
|
+
name: rake
|
129
|
+
requirement: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - "~>"
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '10.0'
|
134
|
+
type: :development
|
135
|
+
prerelease: false
|
136
|
+
version_requirements: !ruby/object:Gem::Requirement
|
137
|
+
requirements:
|
138
|
+
- - "~>"
|
139
|
+
- !ruby/object:Gem::Version
|
140
|
+
version: '10.0'
|
141
|
+
- !ruby/object:Gem::Dependency
|
142
|
+
name: rspec
|
143
|
+
requirement: !ruby/object:Gem::Requirement
|
144
|
+
requirements:
|
145
|
+
- - "~>"
|
146
|
+
- !ruby/object:Gem::Version
|
147
|
+
version: '3.5'
|
148
|
+
type: :development
|
149
|
+
prerelease: false
|
150
|
+
version_requirements: !ruby/object:Gem::Requirement
|
151
|
+
requirements:
|
152
|
+
- - "~>"
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: '3.5'
|
155
|
+
- !ruby/object:Gem::Dependency
|
156
|
+
name: minitest
|
157
|
+
requirement: !ruby/object:Gem::Requirement
|
158
|
+
requirements:
|
159
|
+
- - "~>"
|
160
|
+
- !ruby/object:Gem::Version
|
161
|
+
version: '5.9'
|
162
|
+
type: :development
|
163
|
+
prerelease: false
|
164
|
+
version_requirements: !ruby/object:Gem::Requirement
|
165
|
+
requirements:
|
166
|
+
- - "~>"
|
167
|
+
- !ruby/object:Gem::Version
|
168
|
+
version: '5.9'
|
169
|
+
- !ruby/object:Gem::Dependency
|
170
|
+
name: rubocop
|
171
|
+
requirement: !ruby/object:Gem::Requirement
|
172
|
+
requirements:
|
173
|
+
- - "~>"
|
174
|
+
- !ruby/object:Gem::Version
|
175
|
+
version: '0.50'
|
176
|
+
type: :development
|
177
|
+
prerelease: false
|
178
|
+
version_requirements: !ruby/object:Gem::Requirement
|
179
|
+
requirements:
|
180
|
+
- - "~>"
|
181
|
+
- !ruby/object:Gem::Version
|
182
|
+
version: '0.50'
|
183
|
+
description: Command line tool for testing websocket servers using scenarios.
|
184
|
+
email:
|
185
|
+
- kirillvs@mail.ru
|
186
|
+
- root@grandman73.ru
|
187
|
+
- dementiev.vm@gmail.com
|
188
|
+
executables:
|
189
|
+
- wsdirector
|
190
|
+
extensions: []
|
191
|
+
extra_rdoc_files: []
|
192
|
+
files:
|
193
|
+
- CHANGELOG.md
|
194
|
+
- LICENSE.txt
|
195
|
+
- README.md
|
196
|
+
- bin/wsdirector
|
197
|
+
- lib/wsdirector.rb
|
198
|
+
- lib/wsdirector/cli.rb
|
199
|
+
- lib/wsdirector/client.rb
|
200
|
+
- lib/wsdirector/clients_holder.rb
|
201
|
+
- lib/wsdirector/configuration.rb
|
202
|
+
- lib/wsdirector/ext/deep_dup.rb
|
203
|
+
- lib/wsdirector/printer.rb
|
204
|
+
- lib/wsdirector/protocols.rb
|
205
|
+
- lib/wsdirector/protocols/action_cable.rb
|
206
|
+
- lib/wsdirector/protocols/base.rb
|
207
|
+
- lib/wsdirector/result.rb
|
208
|
+
- lib/wsdirector/results_holder.rb
|
209
|
+
- lib/wsdirector/runner.rb
|
210
|
+
- lib/wsdirector/scenario_reader.rb
|
211
|
+
- lib/wsdirector/task.rb
|
212
|
+
- lib/wsdirector/version.rb
|
213
|
+
homepage: https://github.com/palkan/wsdirector
|
214
|
+
licenses:
|
215
|
+
- MIT
|
216
|
+
metadata: {}
|
217
|
+
post_install_message:
|
218
|
+
rdoc_options: []
|
219
|
+
require_paths:
|
220
|
+
- lib
|
221
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
222
|
+
requirements:
|
223
|
+
- - ">="
|
224
|
+
- !ruby/object:Gem::Version
|
225
|
+
version: '0'
|
226
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
227
|
+
requirements:
|
228
|
+
- - ">="
|
229
|
+
- !ruby/object:Gem::Version
|
230
|
+
version: '0'
|
231
|
+
requirements: []
|
232
|
+
rubyforge_project:
|
233
|
+
rubygems_version: 2.6.13
|
234
|
+
signing_key:
|
235
|
+
specification_version: 4
|
236
|
+
summary: Command line tool for testing websocket servers using scenarios.
|
237
|
+
test_files: []
|