anyt-core 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +38 -0
  4. data/bin/anyt +17 -0
  5. data/bin/console +7 -0
  6. data/bin/setup +8 -0
  7. data/lib/anyt/cli.rb +211 -0
  8. data/lib/anyt/client.rb +146 -0
  9. data/lib/anyt/command.rb +83 -0
  10. data/lib/anyt/config.rb +41 -0
  11. data/lib/anyt/dummy/application.rb +86 -0
  12. data/lib/anyt/dummy/config.ru +16 -0
  13. data/lib/anyt/dummy/routes.rb +4 -0
  14. data/lib/anyt/dummy/tmp/development_secret.txt +1 -0
  15. data/lib/anyt/dummy/tmp/local_secret.txt +1 -0
  16. data/lib/anyt/ext/minitest.rb +151 -0
  17. data/lib/anyt/remote_control.rb +33 -0
  18. data/lib/anyt/rpc.rb +44 -0
  19. data/lib/anyt/tests/core/ping_test.rb +23 -0
  20. data/lib/anyt/tests/core/welcome_test.rb +10 -0
  21. data/lib/anyt/tests/features/channel_state_test.rb +81 -0
  22. data/lib/anyt/tests/features/remote_disconnect_test.rb +35 -0
  23. data/lib/anyt/tests/features/server_restart_test.rb +30 -0
  24. data/lib/anyt/tests/request/channel_test.rb +28 -0
  25. data/lib/anyt/tests/request/connection_test.rb +54 -0
  26. data/lib/anyt/tests/request/disconnect_reasons_test.rb +25 -0
  27. data/lib/anyt/tests/request/disconnection_test.rb +148 -0
  28. data/lib/anyt/tests/streams/broadcast_test.rb +65 -0
  29. data/lib/anyt/tests/streams/multiple_clients_test.rb +61 -0
  30. data/lib/anyt/tests/streams/multiple_test.rb +60 -0
  31. data/lib/anyt/tests/streams/single_test.rb +83 -0
  32. data/lib/anyt/tests/streams/stop_test.rb +57 -0
  33. data/lib/anyt/tests/subscriptions/ack_test.rb +39 -0
  34. data/lib/anyt/tests/subscriptions/params_test.rb +29 -0
  35. data/lib/anyt/tests/subscriptions/perform_test.rb +60 -0
  36. data/lib/anyt/tests/subscriptions/transmissions_test.rb +32 -0
  37. data/lib/anyt/tests.rb +62 -0
  38. data/lib/anyt/utils/async_helpers.rb +16 -0
  39. data/lib/anyt/utils.rb +3 -0
  40. data/lib/anyt/version.rb +5 -0
  41. data/lib/anyt.rb +14 -0
  42. metadata +167 -0
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["TERM"] = "#{ENV["TERM"]}color" unless ENV["TERM"]&.match?(/color/)
4
+ require "minitest/spec"
5
+ require "minitest/unit"
6
+ require "minitest/reporters"
7
+
8
+ module Anyt
9
+ # Common tests helpers
10
+ module TestHelpers
11
+ def self.included(base)
12
+ base.let(:client) { build_client(ignore: %w[ping welcome]) }
13
+ base.after { @clients&.each { |client| client.close(allow_messages: true) } }
14
+ end
15
+
16
+ def build_client(**args)
17
+ @clients ||= []
18
+ Anyt::Client.new(**args).tap do |client|
19
+ @clients << client
20
+ end
21
+ end
22
+
23
+ def restart_server!
24
+ if Anyt.config.use_action_cable
25
+ remote_client.restart_action_cable
26
+ else
27
+ Command.restart
28
+ end
29
+ end
30
+
31
+ def remote_client
32
+ @remote_client ||= RemoteControl::Client.connect(Anyt.config.remote_control_port)
33
+ end
34
+
35
+ # Verifies that the actual message Hash is a subset of the expected one
36
+ # (so we can ignore some irrelevant fields)
37
+ def assert_message(expected, actual)
38
+ assert_equal expected, actual.slice(*expected.keys)
39
+ end
40
+
41
+ def assert_includes_message(collection, expected)
42
+ found = collection.find do |el|
43
+ el.slice(*expected.keys) == expected
44
+ end
45
+
46
+ assert found, "Expecte #{collection} to include a message matching #{expected}"
47
+ end
48
+ end
49
+ end
50
+
51
+ module Anyt
52
+ # Namespace for test channels
53
+ module TestChannels; end
54
+
55
+ # Custom #connect handlers management
56
+ module ConnectHandlers
57
+ class << self
58
+ def call(connection)
59
+ handlers_for(connection).each do |(_, handler)|
60
+ connection.reject_unauthorized_connection unless
61
+ connection.instance_eval(&handler)
62
+ end
63
+ end
64
+
65
+ def add(tag, &block)
66
+ handlers << [tag, block]
67
+ end
68
+
69
+ private
70
+
71
+ def handlers_for(connection)
72
+ handlers.select do |(tag, _)|
73
+ connection.params["test"] == tag
74
+ end
75
+ end
76
+
77
+ def handlers
78
+ @handlers ||= []
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ # Kernel extensions
85
+ module Kernel
86
+ ## Wraps `describe` and include shared helpers
87
+ private def feature(*args, &block)
88
+ cls = describe(*args, &block)
89
+ cls.include Anyt::TestHelpers
90
+ cls
91
+ end
92
+ end
93
+
94
+ # Extend Minitest Spec DSL with custom methodss
95
+ module Minitest::Spec::DSL
96
+ # Simplified version of `it` which doesn't care
97
+ # about unique method names
98
+ def scenario(desc, &block)
99
+ block ||= proc { skip "(no tests defined)" }
100
+
101
+ define_method "test_ #{desc}", &block
102
+
103
+ desc
104
+ end
105
+
106
+ # Generates Channel class dynamically and
107
+ # add memoized helper to access its name
108
+ def channel(id = nil, &block)
109
+ class_name = @name.gsub(/\s+/, "_")
110
+ class_name += "_#{id}" if id
111
+ class_name += "_channel"
112
+
113
+ cls = Class.new(ApplicationCable::Channel, &block)
114
+
115
+ Anyt::TestChannels.const_set(class_name.classify, cls)
116
+
117
+ helper_name = id ? "#{id}_channel" : "channel"
118
+
119
+ let(helper_name) { cls.name }
120
+ end
121
+
122
+ # Add new #connect handler
123
+ def connect_handler(tag, &block)
124
+ Anyt::ConnectHandlers.add(tag, &block)
125
+ end
126
+ end
127
+
128
+ module Anyt
129
+ # Patch Minitest load_plugins to disable Rails plugin
130
+ # See: https://github.com/kern/minitest-reporters/issues/230
131
+ module MinitestPatch
132
+ def load_plugins
133
+ super
134
+ extensions.delete("rails")
135
+ end
136
+ end
137
+
138
+ # Patch Spec reporter
139
+ module ReporterPatch # :nodoc:
140
+ def record_print_status(test)
141
+ test_name = test.name.gsub(/^test_/, "").strip
142
+ print(magenta { pad_test(test_name) })
143
+ print_colored_status(test)
144
+ print(" (%.2fs)" % test.time) unless test.time.nil?
145
+ puts
146
+ end
147
+ end
148
+ end
149
+
150
+ Minitest::Reporters::SpecReporter.prepend Anyt::ReporterPatch
151
+ Minitest.singleton_class.prepend Anyt::MinitestPatch
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "drb/drb"
4
+
5
+ module Anyt
6
+ # Invoke commands within the running Ruby (Action Cable) server
7
+ module RemoteControl
8
+ class Server
9
+ class << self
10
+ alias_method :start, :new
11
+ end
12
+
13
+ def initialize(port)
14
+ DRb.start_service(
15
+ "druby://localhost:#{port}",
16
+ Client.new
17
+ )
18
+ end
19
+ end
20
+
21
+ class Client
22
+ def self.connect(port)
23
+ DRb.start_service
24
+
25
+ DRbObject.new_with_uri("druby://localhost:#{port}")
26
+ end
27
+
28
+ def restart_action_cable
29
+ ActionCable.server.restart
30
+ end
31
+ end
32
+ end
33
+ end
data/lib/anyt/rpc.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyt/dummy/application"
4
+ require "anycable-rails"
5
+ require "redis"
6
+
7
+ module Anyt # :nodoc:
8
+ # Runs AnyCable RPC server in the background
9
+ class RPC
10
+ using AsyncHelpers
11
+
12
+ attr_accessor :running
13
+ attr_reader :server
14
+
15
+ def start
16
+ AnyCable.logger.debug "Starting RPC server ..."
17
+
18
+ AnyCable.server_callbacks.each(&:call)
19
+
20
+ @server = AnyCable::GRPC::Server.new(
21
+ host: AnyCable.config.rpc_host,
22
+ **AnyCable.config.to_grpc_params
23
+ )
24
+
25
+ if defined?(::AnyCable::Middlewares::EnvSid)
26
+ AnyCable.middleware.use(::AnyCable::Middlewares::EnvSid)
27
+ end
28
+
29
+ AnyCable.middleware.freeze
30
+
31
+ server.start
32
+
33
+ AnyCable.logger.debug "RPC server started"
34
+ end
35
+ # rubocop: enable Metrics/AbcSize,Metrics/MethodLength
36
+
37
+ def stop
38
+ server&.stop
39
+ end
40
+
41
+ AnyCable.connection_factory = ActionCable.server.config.connection_class.call
42
+ AnyCable.config.log_level = :fatal unless AnyCable.config.debug
43
+ end
44
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Ping" do
4
+ scenario %(
5
+ Client receives pings with timestamps
6
+ ) do
7
+ client = build_client(ignore: ["welcome"])
8
+
9
+ previous_stamp = 0
10
+
11
+ 2.times do
12
+ ping = client.receive
13
+
14
+ current_stamp = ping["message"]
15
+
16
+ assert_equal "ping", ping["type"]
17
+ assert_kind_of Integer, current_stamp
18
+ refute_operator previous_stamp, :>=, current_stamp
19
+
20
+ previous_stamp = current_stamp
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Welcome" do
4
+ scenario %(
5
+ Client receives "welcome" message
6
+ ) do
7
+ client = build_client(ignore: ["ping"])
8
+ assert_message({"type" => "welcome"}, client.receive)
9
+ end
10
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Channel state" do
4
+ channel do
5
+ state_attr_accessor :user, :count
6
+
7
+ def subscribed
8
+ self.user = {name: params["name"]}
9
+ self.count = 1
10
+
11
+ stream_from "state_counts"
12
+ end
13
+
14
+ def tick
15
+ self.count += 2
16
+ transmit({count: count, name: user[:name]})
17
+ end
18
+
19
+ def unsubscribed
20
+ return unless params["notify_disconnect"]
21
+
22
+ ActionCable.server.broadcast("state_counts", {data: "user left: #{user[:name]}"})
23
+ end
24
+ end
25
+
26
+ let(:identifier) { {channel: channel, name: "chipolino"}.to_json }
27
+
28
+ let(:client2) { build_client(ignore: %w[ping welcome]) }
29
+ let(:identifier2) { {channel: channel, name: "chipollone", notify_disconnect: true}.to_json }
30
+
31
+ before do
32
+ subscribe_request = {command: "subscribe", identifier: identifier}
33
+
34
+ client.send(subscribe_request)
35
+
36
+ ack = {
37
+ "identifier" => identifier, "type" => "confirm_subscription"
38
+ }
39
+
40
+ assert_message ack, client.receive
41
+ end
42
+
43
+ scenario %(
44
+ Channel state is kept between commands
45
+ ) do
46
+ perform_request = {
47
+ :command => "message",
48
+ :identifier => identifier,
49
+ "data" => {"action" => "tick"}.to_json
50
+ }
51
+
52
+ client.send(perform_request)
53
+
54
+ msg = {"identifier" => identifier, "message" => {"count" => 3, "name" => "chipolino"}}
55
+
56
+ assert_message msg, client.receive
57
+ end
58
+
59
+ scenario %(
60
+ Channel state is available in #unsubscribe callbacks
61
+ ) do
62
+ subscribe_request = {command: "subscribe", identifier: identifier2}
63
+
64
+ client2.send(subscribe_request)
65
+
66
+ ack = {
67
+ "identifier" => identifier2, "type" => "confirm_subscription"
68
+ }
69
+
70
+ assert_message ack, client2.receive
71
+
72
+ client2.close
73
+
74
+ msg = {
75
+ "identifier" => identifier,
76
+ "message" => {"data" => "user left: chipollone"}
77
+ }
78
+
79
+ assert_message msg, client.receive
80
+ end
81
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Remote disconnect" do
4
+ connect_handler("uid") do
5
+ self.uid = request.params["uid"]
6
+ uid.present?
7
+ end
8
+
9
+ scenario %(
10
+ Close single connection by id
11
+ ) do
12
+ client = build_client(qs: "test=uid&uid=26", ignore: %w[ping])
13
+ assert_message({"type" => "welcome"}, client.receive)
14
+
15
+ # Prevent race conditions when we send disconnect before internal channel subscription has been made
16
+ # (only for Action Cable)
17
+ sleep 1
18
+ ActionCable.server.remote_connections.where(uid: "26").disconnect
19
+
20
+ # Waiting for https://github.com/rails/rails/pull/39544
21
+ unless Anyt.config.use_action_cable
22
+ assert_message(
23
+ {
24
+ "type" => "disconnect",
25
+ "reconnect" => true,
26
+ "reason" => "remote"
27
+ },
28
+ client.receive
29
+ )
30
+ end
31
+
32
+ client.wait_for_close
33
+ assert client.closed?
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Server restart" do
4
+ connect_handler("reasons") do
5
+ next false if request.params[:reason] == "unauthorized"
6
+ true
7
+ end
8
+
9
+ scenario %(
10
+ Client receives disconnect message
11
+ ) do
12
+ client = build_client(
13
+ qs: "test=reasons&reason=server_restart",
14
+ ignore: %(ping)
15
+ )
16
+
17
+ assert_message({"type" => "welcome"}, client.receive)
18
+
19
+ restart_server!
20
+
21
+ assert_message(
22
+ {
23
+ "type" => "disconnect",
24
+ "reconnect" => true,
25
+ "reason" => "server_restart"
26
+ },
27
+ client.receive
28
+ )
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Request" do
4
+ channel do
5
+ delegate :params, to: :connection
6
+
7
+ def subscribed
8
+ reject unless params[:token] == "secret"
9
+ end
10
+ end
11
+
12
+ scenario %(
13
+ Channel has access to request
14
+ ) do
15
+ client = build_client(qs: "token=secret", ignore: %w[ping])
16
+ assert_message({"type" => "welcome"}, client.receive)
17
+
18
+ subscribe_request = {command: "subscribe", identifier: {channel: channel}.to_json}
19
+
20
+ client.send(subscribe_request)
21
+
22
+ ack = {
23
+ "identifier" => {channel: channel}.to_json, "type" => "confirm_subscription"
24
+ }
25
+
26
+ assert_message ack, client.receive
27
+ end
28
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Request" do
4
+ target_host = URI.parse(Anyt.config.target_url).host
5
+
6
+ connect_handler("request_url") do
7
+ request.url =~ /test=request_url/ && request.host == target_host
8
+ end
9
+
10
+ scenario %(
11
+ Url is set during connection
12
+ ) do
13
+ client = build_client(qs: "test=request_url")
14
+ assert_message({"type" => "welcome"}, client.receive)
15
+ end
16
+
17
+ connect_handler("cookies") do
18
+ cookies[:username] == "john green"
19
+ end
20
+
21
+ scenario %(
22
+ Reject when required cookies are not set
23
+ ) do
24
+ client = build_client(qs: "test=cookies")
25
+ client.wait_for_close
26
+ assert client.closed?
27
+ end
28
+
29
+ scenario %(
30
+ Accepts when required cookies are set
31
+ ) do
32
+ client = build_client(qs: "test=cookies", cookies: "username=john green")
33
+ assert_message({"type" => "welcome"}, client.receive)
34
+ end
35
+
36
+ connect_handler("headers") do
37
+ request.headers["X-API-TOKEN"] == "abc"
38
+ end
39
+
40
+ scenario %(
41
+ Reject when required header is missing
42
+ ) do
43
+ client = build_client(qs: "test=headers")
44
+ client.wait_for_close
45
+ assert client.closed?
46
+ end
47
+
48
+ scenario %(
49
+ Accepts when required header is set
50
+ ) do
51
+ client = build_client(qs: "test=headers", headers: {"x-api-token" => "abc"})
52
+ assert_message({"type" => "welcome"}, client.receive)
53
+ end
54
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Request" do
4
+ connect_handler("reasons") do
5
+ next false if request.params[:reason] == "unauthorized"
6
+ true
7
+ end
8
+
9
+ scenario %(
10
+ Receives disconnect message when rejected
11
+ ) do
12
+ client = build_client(qs: "test=reasons&reason=unauthorized")
13
+ assert_message(
14
+ {
15
+ "type" => "disconnect",
16
+ "reconnect" => false,
17
+ "reason" => "unauthorized"
18
+ },
19
+ client.receive
20
+ )
21
+
22
+ client.wait_for_close
23
+ assert client.closed?
24
+ end
25
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ feature "Request" do
4
+ channel(:a) do
5
+ def subscribed
6
+ stream_from "request_a"
7
+ end
8
+
9
+ def unsubscribed
10
+ ActionCable.server.broadcast("request_a", {data: "user left"})
11
+ end
12
+ end
13
+
14
+ channel(:b) do
15
+ def subscribed
16
+ stream_from "request_b"
17
+ end
18
+
19
+ def unsubscribed
20
+ ActionCable.server.broadcast("request_b", {data: "user left"})
21
+ end
22
+ end
23
+
24
+ channel(:c) do
25
+ def subscribed
26
+ stream_from "request_c"
27
+ end
28
+
29
+ def unsubscribed
30
+ ActionCable.server.broadcast("request_c", {data: "user left#{params[:id].presence}"})
31
+ end
32
+ end
33
+
34
+ let(:client2) { build_client(ignore: %w[ping welcome]) }
35
+
36
+ scenario %(
37
+ Client disconnect invokes #unsubscribe callbacks
38
+ for different channels
39
+ ) do
40
+ subscribe_request = {command: "subscribe", identifier: {channel: a_channel}.to_json}
41
+
42
+ client.send(subscribe_request)
43
+
44
+ ack = {
45
+ "identifier" => {channel: a_channel}.to_json, "type" => "confirm_subscription"
46
+ }
47
+
48
+ assert_message ack, client.receive
49
+
50
+ subscribe_request = {command: "subscribe", identifier: {channel: b_channel}.to_json}
51
+
52
+ client.send(subscribe_request)
53
+
54
+ ack = {
55
+ "identifier" => {channel: b_channel}.to_json, "type" => "confirm_subscription"
56
+ }
57
+
58
+ assert_message ack, client.receive
59
+
60
+ subscribe_request = {command: "subscribe", identifier: {channel: a_channel}.to_json}
61
+
62
+ client2.send(subscribe_request)
63
+
64
+ ack = {
65
+ "identifier" => {channel: a_channel}.to_json, "type" => "confirm_subscription"
66
+ }
67
+
68
+ assert_message ack, client2.receive
69
+
70
+ subscribe_request = {command: "subscribe", identifier: {channel: b_channel}.to_json}
71
+
72
+ client2.send(subscribe_request)
73
+
74
+ ack = {
75
+ "identifier" => {channel: b_channel}.to_json, "type" => "confirm_subscription"
76
+ }
77
+
78
+ assert_message ack, client2.receive
79
+
80
+ client2.close
81
+
82
+ msg = {
83
+ "identifier" => {channel: a_channel}.to_json,
84
+ "message" => {"data" => "user left"}
85
+ }
86
+ msg2 = {
87
+ "identifier" => {channel: b_channel}.to_json,
88
+ "message" => {"data" => "user left"}
89
+ }
90
+
91
+ msgs = [client.receive, client.receive]
92
+
93
+ assert_includes_message msgs, msg
94
+ assert_includes_message msgs, msg2
95
+ end
96
+
97
+ scenario %(
98
+ Client disconnect invokes #unsubscribe callbacks
99
+ for multiple subscriptions from the same channel
100
+ ) do
101
+ subscribe_request = {command: "subscribe", identifier: {channel: c_channel}.to_json}
102
+
103
+ client.send(subscribe_request)
104
+
105
+ ack = {
106
+ "identifier" => {channel: c_channel}.to_json, "type" => "confirm_subscription"
107
+ }
108
+
109
+ assert_message ack, client.receive
110
+
111
+ subscribe_request = {command: "subscribe", identifier: {channel: c_channel, id: 1}.to_json}
112
+
113
+ client2.send(subscribe_request)
114
+
115
+ ack = {
116
+ "identifier" => {channel: c_channel, id: 1}.to_json, "type" => "confirm_subscription"
117
+ }
118
+
119
+ assert_message ack, client2.receive
120
+
121
+ subscribe_request = {command: "subscribe", identifier: {channel: c_channel, id: 2}.to_json}
122
+
123
+ client2.send(subscribe_request)
124
+
125
+ ack = {
126
+ "identifier" => {channel: c_channel, id: 2}.to_json, "type" => "confirm_subscription"
127
+ }
128
+
129
+ assert_message ack, client2.receive
130
+
131
+ client2.close
132
+
133
+ msg = {
134
+ "identifier" => {channel: c_channel}.to_json,
135
+ "message" => {"data" => "user left1"}
136
+ }
137
+
138
+ msg2 = {
139
+ "identifier" => {channel: c_channel}.to_json,
140
+ "message" => {"data" => "user left2"}
141
+ }
142
+
143
+ msgs = [client.receive, client.receive]
144
+
145
+ assert_includes_message msgs, msg
146
+ assert_includes_message msgs, msg2
147
+ end
148
+ end