happybara 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f07111257fd0128b45efe622094b7cbadb1904e2
4
+ data.tar.gz: d9dd81e7fe06f279ae1590f17ae5513ba686e0c7
5
+ SHA512:
6
+ metadata.gz: ceae202a3842453f47586a01e7a5818e26f3b96e7da9a226d162edee0153f8a6ef7a6eab40c4216fc8c20dafbc1d4edfeb90e52f2ca47ee0c75d712cfdac8db1
7
+ data.tar.gz: 84b24d10b0b2af0a9a4f1583b99d4cefe71e7ff17067c6464078487266f376994a97d8fccc76479e0653ced15288b68335d0593b0f1ff9d9199d0856d8587c2f
data/lib/happybara.rb ADDED
@@ -0,0 +1,9 @@
1
+ require_relative './happybara/agent'
2
+ require_relative './happybara/callbacks'
3
+ require_relative './happybara/executor'
4
+ require_relative './happybara/serializer'
5
+ require_relative './happybara/synchronizer'
6
+ require_relative './happybara/version'
7
+
8
+ module Happybara
9
+ end
@@ -0,0 +1,117 @@
1
+ require 'colorize'
2
+ require 'singleton'
3
+ require_relative './callbacks'
4
+ require_relative './serializer'
5
+ require_relative './synchronizer'
6
+ require_relative './registry'
7
+
8
+ module Happybara
9
+ class Agent
10
+ include Callbacks
11
+
12
+ attr_accessor :grace_timeout
13
+
14
+ # Create a new Agent instance and configure it.
15
+ #
16
+ # @yield [Happybara::Agent] instance
17
+ # The new agent instance. The block will evaluated in this instance's
18
+ # scope so you can call methods like #on and #before() globally.
19
+ #
20
+ # @return [Happybara::Agent]
21
+ def self.create(&block)
22
+ Happybara::Agent.new.tap do |instance|
23
+ instance.instance_exec do
24
+ block[self]
25
+ end
26
+ end
27
+ end
28
+
29
+ def initialize(serializer: Serializer.new)
30
+ @serializer = serializer
31
+ @reflections = {}
32
+ @mutex = Synchronizer.new({
33
+ on_abrupt_termination: ->() {
34
+ run_callbacks(:abrupt_termination)
35
+ run_after_example
36
+ },
37
+
38
+ on_forced_release: ->() {
39
+ run_callbacks(:forced_release)
40
+ run_after_example
41
+ },
42
+
43
+ on_race_condition: ->() {
44
+ run_callbacks(:race_condition)
45
+ }
46
+ })
47
+ end
48
+
49
+ # Bind the Agent to a websocket to start a testing session.
50
+ #
51
+ # @param [Websocket] socket
52
+ # @param [Object] actor
53
+ # An instance of an object that will be receiving the RPC calls. Any
54
+ # `eval` RPCs will be evaluated in this object's context. If you're
55
+ # using Tubesock and hijacking a Rails controller, you can pass the
56
+ # controller itself as an actor, then you also have access to
57
+ # ApplicationController methods and helpers as RPCs.
58
+ #
59
+ # @return [NilClass]
60
+ def bind(socket, actor)
61
+ @mutex.lock(socket: socket, timeout: grace_timeout) do
62
+ executor = Executor.new(actor: actor, socket: socket, serializer: @serializer)
63
+ executor.run_before_example = method(:run_before_example)
64
+ executor.run_after_example = method(:run_after_example)
65
+ executor.run_around_command = method(:run_around_command)
66
+
67
+ socket.onopen do
68
+ puts "Happybara: client connected.".green
69
+ end
70
+
71
+ socket.onmessage do |payload|
72
+ executor.handle_message(payload)
73
+ end
74
+
75
+ socket.onclose do
76
+ puts "Happybara: client disconnected.".green
77
+ end
78
+ end
79
+
80
+ nil
81
+ end
82
+
83
+ def on_forced_release(&callback)
84
+ on(:forced_release, &callback)
85
+ end
86
+
87
+ def on_abrupt_termination(&callback)
88
+ on(:abrupt_termination, &callback)
89
+ end
90
+
91
+ def on_race_condition(&callback)
92
+ on(:race_condition, &callback)
93
+ end
94
+
95
+ private
96
+
97
+ def run_before_example
98
+ run_callbacks(:before_each)
99
+ end
100
+
101
+ def run_after_example
102
+ run_callbacks(:after_each)
103
+ @mutex.release
104
+ end
105
+
106
+ def run_around_command(message, &block)
107
+ # TODO: support nested procs.. ?
108
+ callback = callbacks[:around_command] && callbacks[:around_command].first
109
+
110
+ if callback
111
+ callback.call(message, block)
112
+ else
113
+ block.call
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,49 @@
1
+ module Happybara
2
+ module Callbacks
3
+ def before(stage, &callback)
4
+ case stage.to_sym
5
+ when :each
6
+ on(:before_each, &callback)
7
+ when :all
8
+ Rails.configuration.after_initialize(&callback)
9
+ else
10
+ fail "Unknown :before stage '#{stage}'"
11
+ end
12
+ end
13
+
14
+ def after(stage, &callback)
15
+ case stage.to_sym
16
+ when :each
17
+ on(:after_each, &callback)
18
+ else
19
+ fail "Unknown :after stage '#{stage}'"
20
+ end
21
+ end
22
+
23
+ def around(stage, &callback)
24
+ case stage.to_sym
25
+ when :command
26
+ on(:around_command, &callback)
27
+ else
28
+ fail "Unknown :around stage '#{stage}'"
29
+ end
30
+ end
31
+
32
+ def on(event, &handler)
33
+ callbacks[event.to_sym] ||= []
34
+ callbacks[event.to_sym] << handler
35
+ end
36
+
37
+ protected
38
+
39
+ def callbacks
40
+ @callbacks ||= {}
41
+ end
42
+
43
+ def run_callbacks(event)
44
+ callbacks.fetch(event).each do |callback|
45
+ callback.call
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,133 @@
1
+ module Happybara
2
+ class Executor
3
+ attr_accessor :run_before_example, :run_after_example, :run_around_command
4
+
5
+ def initialize(actor:, socket:, serializer:)
6
+ @actor = actor
7
+ @socket = socket
8
+ @serializer = serializer
9
+ @registry = Registry.new
10
+ end
11
+
12
+ def handle_message(payload)
13
+ message = @serializer.deserialize payload
14
+ respond = method(:respond_to_message).curry[message['id']]
15
+
16
+ case message['type']
17
+ when 'SETUP'
18
+ handle_setup(message, &respond)
19
+ when 'RPC'
20
+ handle_rpc(message, &respond)
21
+ when 'EVAL'
22
+ handle_eval(message, &respond)
23
+ when 'QUERY'
24
+ handle_query(message, &respond)
25
+ when 'REFLECT'
26
+ handle_reflect(message, &respond)
27
+ when 'TEARDOWN'
28
+ handle_teardown(message, &respond)
29
+ else
30
+ fail "unknown message type #{message['type']}"
31
+ end
32
+ rescue StandardError => e
33
+ puts "#{'=' * 80}\nHandler error!\n#{'-' * 80}"
34
+ puts e.message
35
+ puts e.backtrace
36
+
37
+ respond['error', { details: e.message, backtrace: e.backtrace }]
38
+ end
39
+
40
+ private
41
+
42
+ def respond_to_message(message_id, type, data)
43
+ @socket.send_data({
44
+ id: message_id,
45
+ type: type,
46
+ data: data
47
+ }.to_json)
48
+ end
49
+
50
+ def handle_setup(message, &respond)
51
+ run_before_example[]
52
+
53
+ respond['SETUP_RESPONSE', @serializer.serialize_value(true)]
54
+ end
55
+
56
+ def handle_query(message, &respond)
57
+ @registry.open!(message['data']['klass_name'])
58
+
59
+ klass = message['data']['klass_name'].constantize
60
+ klass.public_instance_methods(true)
61
+ .sort
62
+ .map(&:to_s)
63
+ .reject { |s| s[0] =~ /[\W|_]/ }
64
+ .tap do |visible_instance_methods|
65
+ respond['QUERY_RESPONSE', @serializer.serialize({
66
+ instance_methods: visible_instance_methods
67
+ })]
68
+ end
69
+ end
70
+
71
+ def handle_rpc(message, &respond)
72
+ method_name = message['data']['procedure']
73
+ method_args = message['data']['payload'] || {}
74
+ method_opts = message['data']['options'] || {}
75
+
76
+ fail "Unknown RPC #{method_name}" unless @actor.respond_to?(method_name)
77
+
78
+ result = around_command(message) do
79
+ @actor.send(message['data']['procedure'], **method_args.symbolize_keys)
80
+ end
81
+
82
+ @registry << result
83
+
84
+ respond['RPC_RESPONSE', @serializer.serialize_value(result)]
85
+ end
86
+
87
+ def handle_eval(message, &respond)
88
+ string = message['data']['string']
89
+ options = message['data']['options'] || {}
90
+
91
+ around_command(message) do
92
+ result = @actor.instance_eval { eval(string) }
93
+ @registry << result
94
+ respond['EVAL_RESPONSE', @serializer.serialize_value(result)]
95
+ end
96
+ end
97
+
98
+ def handle_reflect(message, &respond)
99
+ object_id = message['data']['object_id']
100
+ klass_name = message['data']['klass_name']
101
+ method_name = message['data']['method']
102
+ method_args = message['data']['arguments']
103
+ method_opts = message['data']['options'] || {}
104
+
105
+ ref = resolve_reference(message['data'])
106
+
107
+ fail "Object #{object_id} of type #{klass_name} could not be reflected" if ref.nil?
108
+
109
+ rc = around_command(message) do
110
+ ref.send(method_name, *method_args)
111
+ end
112
+
113
+ @registry << rc
114
+
115
+ respond['REFLECT_RESPONSE', @serializer.serialize_value(rc)]
116
+ end
117
+
118
+ def handle_teardown(message, &respond)
119
+ @registry.release!
120
+ run_after_example[]
121
+
122
+ respond['TEARDOWN_RESPONSE', @serializer.serialize_value(true)]
123
+ end
124
+
125
+ def around_command(message, &block)
126
+ run_around_command[message['data'], &block]
127
+ end
128
+
129
+ def resolve_reference(descriptor)
130
+ @registry[descriptor['object_id']]
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,29 @@
1
+ module Happybara
2
+ class Registry
3
+ def initialize()
4
+ @reflections = {}
5
+ end
6
+
7
+ def open!(klass_name)
8
+ @reflections[klass_name.to_s] = {}
9
+ end
10
+
11
+ def <<(object)
12
+ self.tap do
13
+ klass_name = object.class.name.to_s
14
+
15
+ if @reflections[klass_name]
16
+ @reflections[klass_name][object.object_id] = object
17
+ end
18
+ end
19
+ end
20
+
21
+ def [](object_id)
22
+ ObjectSpace._id2ref(object_id) || nil
23
+ end
24
+
25
+ def release!()
26
+ @reflections.clear
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ module Happybara
2
+ class Serializer
3
+ def serialize(result)
4
+ result.as_json
5
+ end
6
+
7
+ def serialize_value(result)
8
+ {
9
+ klass: result.class.name,
10
+ object_id: result.object_id,
11
+ value: result.as_json
12
+ }
13
+ end
14
+
15
+ def serialize_reference(ref)
16
+ {
17
+ klass: ref.class.name,
18
+ object_id: ref.object_id,
19
+ }
20
+ end
21
+
22
+ def deserialize(payload)
23
+ JSON.load(payload, ->(datum) {
24
+ case datum
25
+ when Hash
26
+ datum.each_pair do |key, value|
27
+ if value.is_a?(Hash) && value['$$ref'] == true && value['$$object_id']
28
+ datum[key] = ObjectSpace._id2ref(value['$$object_id']) || nil
29
+ end
30
+ end
31
+
32
+ datum
33
+ else
34
+ datum
35
+ end
36
+ })
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,94 @@
1
+ module Happybara
2
+ class Synchronizer
3
+ def initialize(
4
+ on_forced_release:,
5
+ on_abrupt_termination:,
6
+ on_race_condition: nil
7
+ )
8
+ @on_forced_release = on_forced_release || ->() {}
9
+ @on_abrupt_termination = on_abrupt_termination || ->() {}
10
+ @on_race_condition = on_race_condition || ->() {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def lock(socket:, timeout: 5.0, frequency: 0.1, &block)
15
+ socket.onopen do
16
+ acquire!(timeout: timeout, frequency: frequency, &block)
17
+ end
18
+
19
+ socket.onclose do
20
+ @on_abrupt_termination[] if @mutex.owned? && @mutex.locked?
21
+ end
22
+ end
23
+
24
+ def release
25
+ @mutex.unlock
26
+ end
27
+
28
+ private
29
+
30
+ def acquire!(timeout:, frequency:, &block)
31
+ if @mutex.locked? && !@mutex.owned?
32
+ fail "
33
+ The Happybara agent is being accessed by multiple threads. This
34
+ mode of operation is not currently supported.
35
+
36
+ (This most likely indicates that you have multiple client runners
37
+ communicating with this Rails server concurrently.)
38
+ "
39
+ elsif @mutex.locked?
40
+ @on_race_condition[]
41
+
42
+ begin
43
+ Timer.new(frequency: frequency, timeout: timeout)
44
+ .until { vacant? }
45
+ .otherwise { @on_forced_release[] }
46
+ .start!
47
+ rescue TimeoutError
48
+ @on_forced_release[]
49
+ end
50
+ end
51
+
52
+ @mutex.lock
53
+
54
+ yield
55
+ end
56
+
57
+ def vacant?
58
+ !@mutex.locked?
59
+ end
60
+
61
+ class Timer
62
+ def initialize(frequency:, timeout:)
63
+ @frequency = frequency
64
+ @timeout = timeout
65
+ @until = nil
66
+ @otherwise = nil
67
+ end
68
+
69
+ def until(&block)
70
+ @until = block
71
+ end
72
+
73
+ def otherwise(&block)
74
+ @otherwise = block
75
+ end
76
+
77
+ def start!
78
+ elapsed = 0.0
79
+
80
+ while !@until[]
81
+ elapsed += frequency
82
+
83
+ if elapsed < timeout
84
+ sleep frequency
85
+ elsif @otherwise
86
+ @otherwise[]
87
+ else
88
+ raise TimeoutError.new
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,3 @@
1
+ module Happybara
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,7 @@
1
+ require 'fileutils'
2
+ require 'spec_helper'
3
+
4
+ describe Happybara::Agent do
5
+ it 'is constructible' do
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ require 'fileutils'
2
+ require 'spec_helper'
3
+
4
+ describe Happybara::Serializer do
5
+ describe '#deserialize' do
6
+ it 'resolves references' do
7
+ some_object = Object.new
8
+
9
+ result = subject.deserialize({
10
+ foo: {
11
+ '$$ref': true,
12
+ '$$object_id': some_object.object_id
13
+ }
14
+ }.to_json)
15
+
16
+ expect(result['foo']).to be(some_object)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ ENV['RAILS_ENV'] ||= "test"
2
+
3
+ require 'pry'
4
+ require_relative '../lib/happybara'
5
+ require_relative './support/bootstrap'
6
+
7
+ RSpec.configure do |config|
8
+ config.expect_with :rspec do |expectations|
9
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
10
+ end
11
+
12
+ config.mock_with :rspec do |mocks|
13
+ mocks.verify_partial_doubles = true
14
+ end
15
+
16
+ config.shared_context_metadata_behavior = :apply_to_host_groups
17
+ end
@@ -0,0 +1,38 @@
1
+ # This dance is necessary to subvert railtie from resolving get_smart's Rails
2
+ # application as the "Rails.application"; we really only need a dummy
3
+ # application that can host some routes, and if this gem was sourced elsewhere
4
+ # we could just define such an application directly but since it's sourced
5
+ # within get_smart, railtie will resolve the root `config.ru` and cause sadness.
6
+ #
7
+ # Be happy, unless you're upgrading Rails and this gets broken. :-)
8
+
9
+ require 'rails/application'
10
+ require 'rails/configuration'
11
+ require 'rails/railtie'
12
+ require 'active_support'
13
+
14
+ module Rails
15
+ class << self
16
+ attr_writer :app_class, :application
17
+ end
18
+ end
19
+
20
+ module Happybara
21
+ class TestApplication < ::Rails::Application
22
+ class << self
23
+ def find_root(*)
24
+ Pathname.new File.realpath File.expand_path(File.dirname(__FILE__))
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ require 'rails'
31
+ require 'action_view'
32
+ require 'action_controller'
33
+
34
+ Happybara::TestApplication.configure do
35
+ config.eager_load = ENV['COVERAGE'] != '1'
36
+ end
37
+
38
+ Happybara::TestApplication.initialize!
File without changes
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: happybara
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ahmad Amireh
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-04-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '6'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '6'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rspec
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.5'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.5'
47
+ description: |
48
+ Happybara is an integration testing platform providing a webserver in Ruby that
49
+ accepts connections over a websocket where test clients can dispatch RPCs to
50
+ Ruby from a different language like JavaScript.
51
+ email:
52
+ - ahmad@amireh.net
53
+ executables: []
54
+ extensions: []
55
+ extra_rdoc_files: []
56
+ files:
57
+ - lib/happybara.rb
58
+ - lib/happybara/agent.rb
59
+ - lib/happybara/callbacks.rb
60
+ - lib/happybara/executor.rb
61
+ - lib/happybara/registry.rb
62
+ - lib/happybara/serializer.rb
63
+ - lib/happybara/synchronizer.rb
64
+ - lib/happybara/version.rb
65
+ - spec/lib/happybara/agent_spec.rb
66
+ - spec/lib/happybara/serializer_spec.rb
67
+ - spec/spec_helper.rb
68
+ - spec/support/bootstrap.rb
69
+ - spec/support/log/test.log
70
+ homepage: https://github.com/amireh/happybara
71
+ licenses:
72
+ - BSD-3
73
+ metadata: {}
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubyforge_project:
90
+ rubygems_version: 2.4.5.1
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: An integration test server using Websockets.
94
+ test_files:
95
+ - spec/lib/happybara/agent_spec.rb
96
+ - spec/lib/happybara/serializer_spec.rb
97
+ - spec/spec_helper.rb
98
+ - spec/support/bootstrap.rb
99
+ - spec/support/log/test.log
100
+ has_rdoc: