wsdirector-cli 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/wsdirector-cli.svg)](https://rubygems.org/gems/wsdirector-cli) [![CircleCI](https://circleci.com/gh/palkan/wsdirector.svg?style=svg)](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: []
|