anyt 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyt
4
+ # Synchronous websocket client
5
+ # Based on https://github.com/rails/rails/blob/v5.0.1/actioncable/test/client_test.rb
6
+ class Client
7
+ require "websocket-client-simple"
8
+ require "concurrent"
9
+
10
+ class TimeoutError < StandardError; end
11
+
12
+ WAIT_WHEN_EXPECTING_EVENT = 5
13
+ WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5
14
+
15
+ # rubocop: disable Metrics/AbcSize
16
+ # rubocop: disable Metrics/MethodLength
17
+ def initialize(
18
+ ignore: [], url: Anyt.config.target_url, qs: "",
19
+ cookies: "", headers: {}
20
+ )
21
+ ignore_message_types = @ignore_message_types = ignore
22
+ messages = @messages = Queue.new
23
+ closed = @closed = Concurrent::Event.new
24
+ has_messages = @has_messages = Concurrent::Semaphore.new(0)
25
+
26
+ headers = headers.merge('cookie' => cookies)
27
+
28
+ open = Concurrent::Promise.new
29
+
30
+ @ws = WebSocket::Client::Simple.connect(
31
+ url + "?#{qs}",
32
+ headers: headers
33
+ ) do |ws|
34
+ ws.on(:error) do |event|
35
+ event = RuntimeError.new(event.message) unless event.is_a?(Exception)
36
+
37
+ if open.pending?
38
+ open.fail(event)
39
+ else
40
+ messages << event
41
+ has_messages.release
42
+ end
43
+ end
44
+
45
+ ws.on(:open) do |_event|
46
+ open.set(true)
47
+ end
48
+
49
+ ws.on(:message) do |event|
50
+ if event.type == :close
51
+ closed.set
52
+ else
53
+ message = JSON.parse(event.data)
54
+
55
+ next if ignore_message_types.include?(message["type"])
56
+ messages << message
57
+ has_messages.release
58
+ end
59
+ end
60
+
61
+ ws.on(:close) do |_event|
62
+ closed.set
63
+ end
64
+ end
65
+
66
+ open.wait!(WAIT_WHEN_EXPECTING_EVENT)
67
+ end
68
+ # rubocop: enable Metrics/AbcSize
69
+ # rubocop: enable Metrics/MethodLength
70
+
71
+ def receive(timeout: WAIT_WHEN_EXPECTING_EVENT)
72
+ raise TimeoutError, "Timed out to receive message" unless
73
+ @has_messages.try_acquire(1, timeout)
74
+
75
+ msg = @messages.pop(true)
76
+ raise msg if msg.is_a?(Exception)
77
+
78
+ msg
79
+ end
80
+
81
+ def send(message)
82
+ @ws.send(JSON.generate(message))
83
+ end
84
+
85
+ def close
86
+ sleep WAIT_WHEN_NOT_EXPECTING_EVENT
87
+
88
+ raise "#{@messages.size} messages unprocessed" unless @messages.empty?
89
+
90
+ @ws.close
91
+ wait_for_close
92
+ end
93
+
94
+ def wait_for_close
95
+ @closed.wait(WAIT_WHEN_EXPECTING_EVENT)
96
+ end
97
+
98
+ def closed?
99
+ @closed.set?
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyt
4
+ # Runs system command (websocket server)
5
+ module Command
6
+ class << self
7
+ attr_accessor :running
8
+
9
+ # rubocop: disable Metrics/MethodLength
10
+ # rubocop: disable Metrics/AbcSize
11
+ def run
12
+ return if @running
13
+
14
+ Anycable.logger.debug "Running command: #{Anyt.config.command}"
15
+
16
+ out = Anycable.config.debug ? STDOUT : IO::NULL
17
+
18
+ @pid = Process.spawn(
19
+ Anyt.config.command,
20
+ out: out,
21
+ err: out
22
+ )
23
+
24
+ Process.detach(@pid)
25
+
26
+ Anycable.logger.debug "Command PID: #{@pid}"
27
+
28
+ @running = true
29
+
30
+ sleep Anyt.config.wait_command
31
+ end
32
+ # rubocop: enable Metrics/MethodLength
33
+ # rubocop: enable Metrics/AbcSize
34
+
35
+ def stop
36
+ return unless @running
37
+
38
+ Anycable.logger.debug "Terminate PID: #{@pid}"
39
+
40
+ Process.kill("SIGKILL", @pid)
41
+
42
+ @running = false
43
+ end
44
+
45
+ def running?
46
+ @running == true
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "anyway"
4
+
5
+ module Anyt
6
+ # Anyt configuration
7
+ class Config < Anyway::Config
8
+ attr_config :command,
9
+ :only_tests,
10
+ target_url: "ws://localhost:9292/",
11
+ wait_command: 2
12
+
13
+ def filter_tests?
14
+ !only_tests.nil?
15
+ end
16
+
17
+ def tests_filter
18
+ @tests_filter ||= begin
19
+ /(#{only_tests.join('|')})/
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "action_cable"
5
+ require "action_dispatch/middleware/cookies"
6
+
7
+ module ApplicationCable
8
+ class Connection < ActionCable::Connection::Base
9
+ delegate :params, to: :request
10
+
11
+ def connect
12
+ logger.info "Connected"
13
+ Anyt::ConnectHandlers.call(self)
14
+ end
15
+
16
+ def disconnect
17
+ logger.info "Disconnected"
18
+ end
19
+ end
20
+ end
21
+
22
+ module ApplicationCable
23
+ class Channel < ActionCable::Channel::Base
24
+ end
25
+ end
26
+
27
+ ActionCable.server.config.cable = { "adapter" => "redis" }
28
+ ActionCable.server.config.connection_class = -> { ApplicationCable::Connection }
29
+ ActionCable.server.config.disable_request_forgery_protection = true
30
+ ActionCable.server.config.logger =
31
+ Rails.logger = Logger.new(STDOUT).tap { |l| l.level = Logger::DEBUG }
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./application"
4
+
5
+ require_relative "../tests"
6
+
7
+ # Load channels from tests
8
+ Anyt::Tests.load_all_tests
9
+
10
+ run ActionCable.server
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/spec"
4
+ require "minitest/reporters"
5
+
6
+ module Anyt
7
+ # Common tests helpers
8
+ module TestHelpers
9
+ def self.included(base)
10
+ base.let(:client) { build_client(ignore: %w[ping welcome]) }
11
+ end
12
+
13
+ def build_client(*args)
14
+ Anyt::Client.new(*args)
15
+ end
16
+ end
17
+ end
18
+
19
+ module Anyt
20
+ # Namespace for test channels
21
+ module TestChannels; end
22
+
23
+ # Custom #connect handlers management
24
+ module ConnectHandlers
25
+ class << self
26
+ def call(connection)
27
+ handlers_for(connection).each do |(_, handler)|
28
+ connection.reject_unauthorized_connection unless
29
+ connection.instance_eval(&handler)
30
+ end
31
+ end
32
+
33
+ def add(tag, &block)
34
+ handlers << [tag, block]
35
+ end
36
+
37
+ private
38
+
39
+ def handlers_for(connection)
40
+ handlers.select do |(tag, _)|
41
+ connection.params['test'] == tag
42
+ end
43
+ end
44
+
45
+ def handlers
46
+ @handlers ||= []
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # Kernel extensions
53
+ module Kernel
54
+ ## Wraps `describe` and include shared helpers
55
+ private def feature(*args, &block)
56
+ cls = describe(*args, &block)
57
+ cls.include Anyt::TestHelpers
58
+ cls
59
+ end
60
+ end
61
+
62
+ # Extend Minitest Spec DSL with custom methodss
63
+ module Minitest::Spec::DSL
64
+ # Simplified version of `it` which doesn't care
65
+ # about unique method names
66
+ def scenario(desc, &block)
67
+ block ||= proc { skip "(no tests defined)" }
68
+
69
+ define_method "test_ #{desc}", &block
70
+
71
+ desc
72
+ end
73
+
74
+ # Generates Channel class dynamically and
75
+ # add memoized helper to access its name
76
+ def channel(id = nil, &block)
77
+ class_name = @name.gsub(/\s+/, '_')
78
+ class_name += "_#{id}" if id
79
+ class_name += "_channel"
80
+
81
+ cls = Class.new(ApplicationCable::Channel, &block)
82
+
83
+ Anyt::TestChannels.const_set(class_name.classify, cls)
84
+
85
+ helper_name = id ? "#{id}_channel" : "channel"
86
+
87
+ let(helper_name) { cls.name }
88
+ end
89
+
90
+ # Add new #connect handler
91
+ def connect_handler(tag, &block)
92
+ Anyt::ConnectHandlers.add(tag, &block)
93
+ end
94
+ end
95
+
96
+ module Anyt
97
+ # Patch Minitest load_plugins to disable Rails plugin
98
+ # See: https://github.com/kern/minitest-reporters/issues/230
99
+ module MinitestPatch
100
+ def load_plugins
101
+ super
102
+ extensions.delete('rails')
103
+ end
104
+ end
105
+
106
+ # Patch Spec reporter
107
+ module ReporterPatch # :nodoc:
108
+ def record_print_status(test)
109
+ test_name = test.name.gsub(/^test_/, '').strip
110
+ print pad_test(test_name)
111
+ print_colored_status(test)
112
+ print(" (%.2fs)" % test.time) unless test.time.nil?
113
+ puts
114
+ end
115
+ end
116
+ end
117
+
118
+ Minitest::Reporters::SpecReporter.prepend Anyt::ReporterPatch
119
+ Minitest.singleton_class.prepend Anyt::MinitestPatch
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyt # :nodoc:
4
+ require "anyt/dummy/application"
5
+ require "anycable"
6
+
7
+ # Runs AnyCable RPC server in the background
8
+ module RPC
9
+ using AsyncHelpers
10
+
11
+ class << self
12
+ attr_accessor :running
13
+
14
+ def start
15
+ Anycable.logger.debug "Starting RPC server ..."
16
+
17
+ @thread = Thread.new { Anycable::Server.start }
18
+ @thread.abort_on_exception = true
19
+
20
+ wait(2) { running? }
21
+
22
+ Anycable.logger.debug "RPC server started"
23
+ end
24
+
25
+ def stop
26
+ return unless running?
27
+
28
+ Anycable::Server.grpc_server.stop
29
+ end
30
+
31
+ def running?
32
+ Anycable::Server.grpc_server&.running_state == :running
33
+ end
34
+ end
35
+
36
+ Anycable.configure do |config|
37
+ config.connection_factory = ActionCable.server.config.connection_class.call
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anyt
4
+ # Loads and runs test cases
5
+ module Tests
6
+ require "anyt/client"
7
+ require_relative "ext/minitest"
8
+
9
+ class << self
10
+ # Run all loaded tests
11
+ def run
12
+ Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new
13
+
14
+ Anycable.logger.debug "Run tests against: #{Anyt.config.target_url}"
15
+ Minitest.run
16
+ end
17
+
18
+ # Load tests code (filtered if present)
19
+ #
20
+ # NOTE: We should run this before launching RPC server
21
+
22
+ # rubocop:disable Metrics/AbcSize
23
+ # rubocop:disable Metrics/MethodLength
24
+ def load_tests
25
+ return load_all_tests unless Anyt.config.filter_tests?
26
+
27
+ pattern = File.expand_path("tests/**/*.rb", __dir__)
28
+ skipped = []
29
+ filter = Anyt.config.tests_filter
30
+
31
+ Dir.glob(pattern).each do |file|
32
+ if file.match?(filter)
33
+ require file
34
+ else
35
+ skipped << file.gsub(File.join(__dir__, 'tests/'), '').gsub('_test.rb', '')
36
+ end
37
+ end
38
+
39
+ $stdout.print "Skipping tests: #{skipped.join(', ')}\n"
40
+ end
41
+
42
+ # Load all test files
43
+ def load_all_tests
44
+ pattern = File.expand_path("tests/**/*.rb", __dir__)
45
+
46
+ Dir.glob(pattern).each { |file| require file }
47
+ end
48
+ end
49
+ end
50
+ 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["type"], "ping"
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