wsdirector-core 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/lib/.rbnext/2.7/wsdirector/cli.rb +152 -0
- data/lib/.rbnext/2.7/wsdirector/client.rb +112 -0
- data/lib/.rbnext/2.7/wsdirector/protocols/base.rb +288 -0
- data/lib/.rbnext/2.7/wsdirector/protocols/phoenix.rb +102 -0
- data/lib/.rbnext/2.7/wsdirector/scenario_reader.rb +172 -0
- data/lib/.rbnext/3.0/wsdirector/ext/formatting.rb +38 -0
- data/lib/.rbnext/3.0/wsdirector/protocols/base.rb +288 -0
- data/lib/.rbnext/3.1/wsdirector/cli.rb +152 -0
- data/lib/.rbnext/3.1/wsdirector/protocols/action_cable.rb +95 -0
- data/lib/.rbnext/3.1/wsdirector/protocols/phoenix.rb +102 -0
- data/lib/.rbnext/3.1/wsdirector/runner.rb +60 -0
- data/lib/.rbnext/3.1/wsdirector/scenario_reader.rb +172 -0
- data/lib/.rbnext/3.1/wsdirector/task.rb +56 -0
- data/lib/wsdirector/version.rb +1 -1
- metadata +15 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9f1fd8da87e97c9a82a6f0505a8d4a7ef28a9dabb0a75809ee60b1739481e08c
|
4
|
+
data.tar.gz: 77f4c9ab2836f8f1e407c6376ee1b2d73b07ed1963770f5d38837bd24c5a598c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 935e69a837cf42cb0bb3121a8f3f9b415534c492245b350549cf2f0f6a839f382a9e8167179c7db00c0157e4312d0a7efc6480fa9e5bac65076a3a20d9a26cd9
|
7
|
+
data.tar.gz: 61b7ee833e4a236167ef4ede04354b46d44bbc29cdc56b6caa05d3d28f5f0588131c933f3f5ab59833b1d74dfaca509b6a30f1b0bc3280591784b7cd211a448e
|
data/CHANGELOG.md
CHANGED
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
require "uri"
|
5
|
+
|
6
|
+
require "wsdirector"
|
7
|
+
|
8
|
+
module WSDirector
|
9
|
+
# Command line interface for WsDirector
|
10
|
+
class CLI
|
11
|
+
class Configuration
|
12
|
+
attr_accessor :ws_url, :scenario_path, :colorize, :scale,
|
13
|
+
:sync_timeout, :json_scenario, :subprotocol, :verbose
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
reset!
|
17
|
+
end
|
18
|
+
|
19
|
+
def colorize?
|
20
|
+
colorize == true
|
21
|
+
end
|
22
|
+
|
23
|
+
# Restore to defaults
|
24
|
+
def reset!
|
25
|
+
@scale = 1
|
26
|
+
@colorize = $stdout.tty?
|
27
|
+
@sync_timeout = 5
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
attr_reader :config
|
32
|
+
|
33
|
+
def run
|
34
|
+
@config = Configuration.new
|
35
|
+
|
36
|
+
parse_args!
|
37
|
+
|
38
|
+
begin
|
39
|
+
require "colorize" if config.colorize?
|
40
|
+
rescue LoadError
|
41
|
+
config.colorize = false
|
42
|
+
warn "Install colorize to use colored output"
|
43
|
+
end
|
44
|
+
|
45
|
+
connection_options = {
|
46
|
+
subprotocol: config.subprotocol
|
47
|
+
}.compact
|
48
|
+
|
49
|
+
scenario = config.scenario_path || config.json_scenario
|
50
|
+
|
51
|
+
config.ws_url = "ws://#{config.ws_url}" unless config.ws_url.start_with?(/wss?:\/\//)
|
52
|
+
|
53
|
+
url = config.ws_url
|
54
|
+
scale = config.scale
|
55
|
+
sync_timeout = config.sync_timeout
|
56
|
+
colorize = config.colorize
|
57
|
+
|
58
|
+
logger = $stdout if config.verbose
|
59
|
+
|
60
|
+
result = WSDirector.run(
|
61
|
+
scenario,
|
62
|
+
url: url,
|
63
|
+
connection_options: connection_options,
|
64
|
+
scale: scale,
|
65
|
+
sync_timeout: sync_timeout,
|
66
|
+
logger: logger,
|
67
|
+
colorize: colorize
|
68
|
+
)
|
69
|
+
|
70
|
+
puts "\n\n" if config.verbose
|
71
|
+
|
72
|
+
result.print_summary(colorize: config.colorize?)
|
73
|
+
result.success? || exit(1)
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
FILE_FORMAT = /.+.(json|yml)\z/
|
79
|
+
private_constant :FILE_FORMAT
|
80
|
+
|
81
|
+
def parse_args!
|
82
|
+
parser = OptionParser.new do |opts|
|
83
|
+
opts.banner = "Usage: wsdirector scenario_path ws_url [options]"
|
84
|
+
|
85
|
+
opts.on("-s SCALE", "--scale=SCALE", Integer, "Scale factor") do |_1|
|
86
|
+
config.scale = _1
|
87
|
+
end
|
88
|
+
|
89
|
+
opts.on("-t TIMEOUT", "--timeout=TIMEOUT", Integer, "Synchronization (wait_all) timeout") do |_1|
|
90
|
+
config.sync_timeout = _1
|
91
|
+
end
|
92
|
+
|
93
|
+
opts.on("-i JSON", "--include=JSON", String, "Include JSON to parse") do |_1|
|
94
|
+
config.json_scenario = _1
|
95
|
+
end
|
96
|
+
|
97
|
+
opts.on("-u URL", "--url=URL", Object, "Websocket server URL") do |_1|
|
98
|
+
config.ws_url = _1
|
99
|
+
end
|
100
|
+
|
101
|
+
opts.on("-f PATH", "--file=PATH", String, "Scenario path") do |_1|
|
102
|
+
config.scenario_path = _1
|
103
|
+
end
|
104
|
+
|
105
|
+
opts.on("--subprotocol=VALUE", String, "WebSocket subprotocol") do |_1|
|
106
|
+
config.subprotocol = _1
|
107
|
+
end
|
108
|
+
|
109
|
+
opts.on("-c", "--[no-]color", "Colorize output") do |_1|
|
110
|
+
config.colorize = _1
|
111
|
+
end
|
112
|
+
|
113
|
+
opts.on("-v", "--version", "Print version") do
|
114
|
+
$stdout.puts WSDirector::VERSION
|
115
|
+
exit 0
|
116
|
+
end
|
117
|
+
|
118
|
+
opts.on("-vv", "Print verbose logs") do
|
119
|
+
config.verbose = true
|
120
|
+
end
|
121
|
+
|
122
|
+
opts.on("-r", "--require=PATH", "Load Ruby file (e.g., protocol)") do |_1|
|
123
|
+
Kernel.load(_1)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
parser.parse!
|
128
|
+
|
129
|
+
unless config.scenario_path
|
130
|
+
config.scenario_path = ARGV.grep(FILE_FORMAT).last
|
131
|
+
end
|
132
|
+
|
133
|
+
unless config.ws_url
|
134
|
+
config.ws_url = ARGV.grep(URI::DEFAULT_PARSER.make_regexp).last
|
135
|
+
end
|
136
|
+
|
137
|
+
check_for_errors
|
138
|
+
end
|
139
|
+
|
140
|
+
def check_for_errors
|
141
|
+
if config.json_scenario.nil?
|
142
|
+
raise(Error, "Scenario is missing") unless config.scenario_path
|
143
|
+
|
144
|
+
unless File.file?(config.scenario_path)
|
145
|
+
raise(Error, "File doesn't exist # config.scenario_path}")
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
raise(Error, "Websocket server url is missing") unless config.ws_url
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
@@ -0,0 +1,112 @@
|
|
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
|
+
include CGI::Escape
|
10
|
+
|
11
|
+
WAIT_WHEN_EXPECTING_EVENT = 5
|
12
|
+
|
13
|
+
attr_reader :ws, :id
|
14
|
+
|
15
|
+
# Create new WebSocket client and connect to WSDirector
|
16
|
+
# ws URL.
|
17
|
+
#
|
18
|
+
# Optionally provide an ignore pattern (to ignore incoming message,
|
19
|
+
# for example, pings)
|
20
|
+
def initialize(url:, ignore: nil, intercept: nil, subprotocol: nil, headers: nil, id: nil, query: nil, cookies: nil)
|
21
|
+
@ignore = ignore
|
22
|
+
@interceptor = intercept
|
23
|
+
@mailbox = []
|
24
|
+
|
25
|
+
has_messages = @has_messages = Concurrent::Semaphore.new(0)
|
26
|
+
messages = @messages = Queue.new
|
27
|
+
open = Concurrent::Promise.new
|
28
|
+
client = self
|
29
|
+
|
30
|
+
options = {}
|
31
|
+
|
32
|
+
if subprotocol
|
33
|
+
headers ||= {}
|
34
|
+
headers["Sec-WebSocket-Protocol"] = subprotocol
|
35
|
+
end
|
36
|
+
|
37
|
+
if cookies
|
38
|
+
headers ||= {}
|
39
|
+
headers["Cookie"] = cookies.map { |_1, _2| "#{_1}=#{escape(_2.to_s)}" }.join("; ")
|
40
|
+
end
|
41
|
+
|
42
|
+
if query
|
43
|
+
url = "#{url}?#{query.map { |_1, _2| "#{_1}=#{escape(_2.to_s)}" }.join("&")}"
|
44
|
+
end
|
45
|
+
|
46
|
+
options[:headers] = headers if headers
|
47
|
+
|
48
|
+
@id = id || SecureRandom.hex(6)
|
49
|
+
@ws = WebSocket::Client::Simple.connect(url, options) do |ws|
|
50
|
+
ws.on(:open) do |_event|
|
51
|
+
open.set(true)
|
52
|
+
end
|
53
|
+
|
54
|
+
ws.on :message do |msg|
|
55
|
+
data = msg.data
|
56
|
+
next if data.empty?
|
57
|
+
next if client.ignored?(data)
|
58
|
+
next if client.intercepted?(data)
|
59
|
+
messages << data
|
60
|
+
has_messages.release
|
61
|
+
end
|
62
|
+
|
63
|
+
ws.on :error do |e|
|
64
|
+
messages << Error.new("WebSocket Error #{e.inspect} #{e.backtrace}")
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
open.wait!(WAIT_WHEN_EXPECTING_EVENT)
|
69
|
+
rescue Errno::ECONNREFUSED
|
70
|
+
raise Error, "Failed to connect to #{url}"
|
71
|
+
end
|
72
|
+
|
73
|
+
def each_message
|
74
|
+
@mailbox.dup.each.with_index do |msg, i|
|
75
|
+
yield msg, i
|
76
|
+
end
|
77
|
+
|
78
|
+
loop do
|
79
|
+
msg = receive
|
80
|
+
@mailbox << msg
|
81
|
+
yield msg, (@mailbox.size - 1)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def receive(timeout = WAIT_WHEN_EXPECTING_EVENT)
|
86
|
+
@has_messages.try_acquire(1, timeout)
|
87
|
+
msg = @messages.pop(true)
|
88
|
+
raise msg if msg.is_a?(Exception)
|
89
|
+
msg
|
90
|
+
end
|
91
|
+
|
92
|
+
# Push message back to the mailbox (when it doesn't match the expectation)
|
93
|
+
def consumed(id)
|
94
|
+
@mailbox.delete_at(id)
|
95
|
+
end
|
96
|
+
|
97
|
+
def send(msg)
|
98
|
+
@ws.send(msg)
|
99
|
+
end
|
100
|
+
|
101
|
+
def ignored?(msg)
|
102
|
+
return false unless @ignore
|
103
|
+
@ignore.any? { |pattern| msg =~ Regexp.new(pattern) }
|
104
|
+
end
|
105
|
+
|
106
|
+
def intercepted?(msg)
|
107
|
+
return false unless @interceptor
|
108
|
+
|
109
|
+
instance_exec(msg, &@interceptor)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,288 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "time"
|
4
|
+
require "wsdirector/ext/formatting"
|
5
|
+
|
6
|
+
module WSDirector
|
7
|
+
module Protocols
|
8
|
+
using Ext::Formatting
|
9
|
+
|
10
|
+
using(Module.new do
|
11
|
+
refine ::Object do
|
12
|
+
def matches?(other)
|
13
|
+
self == other
|
14
|
+
end
|
15
|
+
|
16
|
+
def partially_matches?(other)
|
17
|
+
self == other
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
refine ::Array do
|
22
|
+
def matches?(actual)
|
23
|
+
if actual.is_a?(::String)
|
24
|
+
actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
|
25
|
+
end
|
26
|
+
|
27
|
+
return false unless actual
|
28
|
+
|
29
|
+
each.with_index do |_1, _2|
|
30
|
+
return false unless _1.matches?(actual[_2])
|
31
|
+
end
|
32
|
+
|
33
|
+
true
|
34
|
+
end
|
35
|
+
|
36
|
+
def partially_matches?(actual)
|
37
|
+
if actual.is_a?(::String)
|
38
|
+
actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
|
39
|
+
end
|
40
|
+
|
41
|
+
return false unless actual
|
42
|
+
|
43
|
+
each.with_index do |_1, _2|
|
44
|
+
return false unless _1.partially_matches?(actual[_2])
|
45
|
+
end
|
46
|
+
|
47
|
+
true
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
refine ::Hash do
|
52
|
+
def matches?(actual)
|
53
|
+
unless actual.is_a?(::Hash)
|
54
|
+
actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
|
55
|
+
end
|
56
|
+
|
57
|
+
return false unless actual
|
58
|
+
|
59
|
+
actual.each_key do |_1|
|
60
|
+
return false unless actual[_1].matches?(self[_1])
|
61
|
+
end
|
62
|
+
|
63
|
+
true
|
64
|
+
end
|
65
|
+
|
66
|
+
def partially_matches?(actual)
|
67
|
+
unless actual.is_a?(::Hash)
|
68
|
+
actual = JSON.parse(actual) rescue nil # rubocop:disable Style/RescueModifier
|
69
|
+
end
|
70
|
+
|
71
|
+
return false unless actual
|
72
|
+
|
73
|
+
each_key do |_1|
|
74
|
+
return false unless self[_1].partially_matches?(actual[_1])
|
75
|
+
end
|
76
|
+
|
77
|
+
true
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end)
|
81
|
+
|
82
|
+
class PartialMatcher
|
83
|
+
attr_reader :obj
|
84
|
+
|
85
|
+
def initialize(obj)
|
86
|
+
@obj = obj
|
87
|
+
end
|
88
|
+
|
89
|
+
def matches?(actual)
|
90
|
+
obj.partially_matches?(actual)
|
91
|
+
end
|
92
|
+
|
93
|
+
def inspect
|
94
|
+
"an object including #{obj.inspect}"
|
95
|
+
end
|
96
|
+
|
97
|
+
def truncate(*__rest__, &__block__) ; obj.truncate(*__rest__, &__block__); end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Base protocol describes basic actions
|
101
|
+
class Base
|
102
|
+
include WSDirector::Utils
|
103
|
+
|
104
|
+
def initialize(task, scale: 1, logger: nil, id: nil, color: nil)
|
105
|
+
@task = task
|
106
|
+
@scale = scale
|
107
|
+
@logger = logger
|
108
|
+
@id = id
|
109
|
+
@color = color
|
110
|
+
end
|
111
|
+
|
112
|
+
def init_client(*__rest__, &__block__)
|
113
|
+
log { "Connecting" }
|
114
|
+
|
115
|
+
@client = build_client(*__rest__, &__block__)
|
116
|
+
|
117
|
+
log(:done) { "Connected" }
|
118
|
+
end
|
119
|
+
|
120
|
+
def handle_step(step)
|
121
|
+
type = step.delete("type")
|
122
|
+
raise Error, "Unknown step: #{type}" unless respond_to?(type)
|
123
|
+
|
124
|
+
return unless task.sampled?(step)
|
125
|
+
|
126
|
+
public_send(type, step)
|
127
|
+
end
|
128
|
+
|
129
|
+
# Sleeps for a specified number of seconds.
|
130
|
+
#
|
131
|
+
# If "shift" is provided than the initial value is
|
132
|
+
# shifted by random number from (-shift, shift).
|
133
|
+
#
|
134
|
+
# Set "debug" to true to print the delay time.
|
135
|
+
def sleep(step)
|
136
|
+
delay = step.fetch("time").to_f
|
137
|
+
shift = step.fetch("shift", 0).to_f
|
138
|
+
|
139
|
+
delay = delay - shift * rand + shift * rand
|
140
|
+
|
141
|
+
log { "Sleep for #{delay}s" }
|
142
|
+
|
143
|
+
Kernel.sleep delay if delay > 0
|
144
|
+
|
145
|
+
log(:done) { "Slept for #{delay}s" }
|
146
|
+
end
|
147
|
+
|
148
|
+
# Prints provided message
|
149
|
+
def debug(step)
|
150
|
+
with_logger do
|
151
|
+
log(nil) { step.fetch("message") }
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def receive(step)
|
156
|
+
expected = step["data"] || PartialMatcher.new(step["data>"])
|
157
|
+
ordered = step["ordered"]
|
158
|
+
|
159
|
+
log { "Receive a message: #{expected.truncate(50)}" }
|
160
|
+
|
161
|
+
received = nil
|
162
|
+
|
163
|
+
client.each_message do |msg, id|
|
164
|
+
received = msg
|
165
|
+
if expected.matches?(msg)
|
166
|
+
client.consumed(id)
|
167
|
+
break
|
168
|
+
end
|
169
|
+
|
170
|
+
if ordered
|
171
|
+
raise UnmatchedExpectationError, prepare_receive_error(expected, received)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
log(:done) { "Received a message: #{received&.truncate(50)}" }
|
176
|
+
rescue ThreadError
|
177
|
+
if received
|
178
|
+
raise UnmatchedExpectationError, prepare_receive_error(expected, received)
|
179
|
+
else
|
180
|
+
raise NoMessageError, "Expected to receive #{expected} but nothing has been received"
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def receive_all(step)
|
185
|
+
messages = step.delete("messages")
|
186
|
+
raise ArgumentError, "Messages array must be specified" if
|
187
|
+
messages.nil? || messages.empty?
|
188
|
+
|
189
|
+
expected =
|
190
|
+
messages.map do |msg|
|
191
|
+
multiplier = parse_multiplier(msg.delete("multiplier") || "1")
|
192
|
+
[msg["data"] || PartialMatcher.new(msg["data>"]), multiplier]
|
193
|
+
end.to_h
|
194
|
+
|
195
|
+
total_expected = expected.values.sum
|
196
|
+
total_received = 0
|
197
|
+
|
198
|
+
log { "Receive #{total_expected} messages" }
|
199
|
+
|
200
|
+
total_expected.times do
|
201
|
+
received = client.receive
|
202
|
+
|
203
|
+
total_received += 1
|
204
|
+
|
205
|
+
match = expected.find { |k, _| k.matches?(received) }
|
206
|
+
|
207
|
+
raise UnexpectedMessageError, "Unexpected message received: #{received}" if
|
208
|
+
match.nil?
|
209
|
+
|
210
|
+
expected[match.first] -= 1
|
211
|
+
expected.delete(match.first) if expected[match.first].zero?
|
212
|
+
end
|
213
|
+
|
214
|
+
log(:done) { "Received #{total_expected} messages" }
|
215
|
+
rescue ThreadError
|
216
|
+
raise NoMessageError,
|
217
|
+
"Expected to receive #{total_expected} messages " \
|
218
|
+
"but received only #{total_received}"
|
219
|
+
end
|
220
|
+
# rubocop: enable Metrics/CyclomaticComplexity
|
221
|
+
|
222
|
+
def send(step)
|
223
|
+
data = step.fetch("data")
|
224
|
+
data = JSON.generate(data) if data.is_a?(Hash)
|
225
|
+
|
226
|
+
client.send(data)
|
227
|
+
|
228
|
+
log(nil) { "Sent message: #{data.truncate(50)}" }
|
229
|
+
end
|
230
|
+
|
231
|
+
def wait_all(_step)
|
232
|
+
log { "Wait all clients" }
|
233
|
+
task.global_holder.wait_all
|
234
|
+
log { "All clients" }
|
235
|
+
end
|
236
|
+
|
237
|
+
def to_proc
|
238
|
+
proc { |step| handle_step(step) }
|
239
|
+
end
|
240
|
+
|
241
|
+
private
|
242
|
+
|
243
|
+
attr_reader :client, :task, :logger, :id, :color
|
244
|
+
|
245
|
+
def build_client(*__rest__, &__block__)
|
246
|
+
Client.new(*__rest__, &__block__)
|
247
|
+
end
|
248
|
+
|
249
|
+
def prepare_receive_error(expected, received)
|
250
|
+
<<~MSG
|
251
|
+
Action failed: #receive
|
252
|
+
-- expected: #{expected.inspect}
|
253
|
+
++ got: #{received} (#{received.class})
|
254
|
+
MSG
|
255
|
+
end
|
256
|
+
|
257
|
+
def log(state = :begin)
|
258
|
+
return unless logger
|
259
|
+
|
260
|
+
if state == :begin
|
261
|
+
@last_event_at = Time.now.to_f
|
262
|
+
end
|
263
|
+
|
264
|
+
done_info =
|
265
|
+
if state == :done
|
266
|
+
" (#{(Time.now.to_f - @last_event_at).duration})"
|
267
|
+
else
|
268
|
+
""
|
269
|
+
end
|
270
|
+
|
271
|
+
msg = "[#{Time.now.strftime("%H:%I:%S.%L")}] client=#{id} #{yield}#{done_info}"
|
272
|
+
|
273
|
+
msg = msg.colorize(color) if color
|
274
|
+
|
275
|
+
logger.puts msg
|
276
|
+
end
|
277
|
+
|
278
|
+
def with_logger
|
279
|
+
return yield if logger
|
280
|
+
|
281
|
+
@logger = $stdout
|
282
|
+
yield
|
283
|
+
ensure
|
284
|
+
@logger = nil
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module WSDirector
|
4
|
+
module Protocols
|
5
|
+
# Phoenix Channels protocol
|
6
|
+
# See https://github.com/phoenixframework/phoenix/blob/master/lib/phoenix/socket/serializers/v2_json_serializer.ex
|
7
|
+
class Phoenix < Base
|
8
|
+
attr_reader :topics_to_join_ref
|
9
|
+
attr_accessor :refs_counter, :join_refs_counter
|
10
|
+
|
11
|
+
def initialize(*__rest__, &__block__)
|
12
|
+
super
|
13
|
+
|
14
|
+
@refs_counter = 3
|
15
|
+
@join_refs_counter = 3
|
16
|
+
@topics_to_join_ref = {}
|
17
|
+
end
|
18
|
+
|
19
|
+
def init_client(**options)
|
20
|
+
options[:query] ||= {}
|
21
|
+
# Make sure we use the v2 of the protocol
|
22
|
+
options[:query][:vsn] = "2.0.0"
|
23
|
+
|
24
|
+
super(**options)
|
25
|
+
end
|
26
|
+
|
27
|
+
def join(step)
|
28
|
+
topic = step.fetch("topic")
|
29
|
+
|
30
|
+
join_ref = join_refs_counter
|
31
|
+
self.join_refs_counter += 1
|
32
|
+
|
33
|
+
ref = refs_counter
|
34
|
+
self.refs_counter += 1
|
35
|
+
|
36
|
+
log { "Joining #{topic} (##{ref})" }
|
37
|
+
|
38
|
+
cmd = new_command(:phx_join, topic, join_ref: join_ref, ref: ref)
|
39
|
+
|
40
|
+
client.send(cmd.to_json)
|
41
|
+
|
42
|
+
receive({"data>" => new_command(:phx_reply, topic, join_ref: join_ref, ref: ref, payload: {"status" => "ok"})})
|
43
|
+
|
44
|
+
log(:done) { "Joined #{topic} (##{ref})" }
|
45
|
+
end
|
46
|
+
|
47
|
+
def leave(step)
|
48
|
+
topic = step.fetch("topic")
|
49
|
+
join_ref = topics_to_join_ref.fetch(topic)
|
50
|
+
|
51
|
+
ref = refs_counter
|
52
|
+
self.refs_counter += 1
|
53
|
+
|
54
|
+
cmd = new_command(:phx_leave, topic, join_ref: join_ref, ref: ref)
|
55
|
+
client.send(cmd.to_json)
|
56
|
+
|
57
|
+
receive({"data>" => new_command(:phx_reply, topic, join_ref: join_ref, ref: ref, payload: {"status" => "ok"})})
|
58
|
+
|
59
|
+
log(nil) { "Left #{topic} (##{join_ref})" }
|
60
|
+
end
|
61
|
+
|
62
|
+
def send(step)
|
63
|
+
return super unless step.key?("topic")
|
64
|
+
|
65
|
+
ref = refs_counter
|
66
|
+
self.refs_counter += 1
|
67
|
+
|
68
|
+
topic = step.fetch("topic")
|
69
|
+
event = step.fetch("event")
|
70
|
+
payload = step["data"]
|
71
|
+
|
72
|
+
super({"data" => new_command(event, topic, payload: payload, ref: ref).to_json})
|
73
|
+
end
|
74
|
+
|
75
|
+
def receive(step)
|
76
|
+
return super unless step.key?("topic")
|
77
|
+
|
78
|
+
topic = step.fetch("topic")
|
79
|
+
event = step.fetch("event")
|
80
|
+
|
81
|
+
key = step.key?("data") ? "data" : "data>"
|
82
|
+
payload = step.fetch(key)
|
83
|
+
|
84
|
+
cmd = new_command(event, topic, payload: payload)
|
85
|
+
|
86
|
+
super({key => cmd})
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def new_command(event, topic, join_ref: nil, ref: nil, payload: {})
|
92
|
+
[
|
93
|
+
join_ref&.to_s,
|
94
|
+
ref&.to_s,
|
95
|
+
topic.to_s,
|
96
|
+
event.to_s,
|
97
|
+
payload
|
98
|
+
]
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|