anyt-core 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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