bond-spy 0.1.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 +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.yardopts +1 -0
- data/Gemfile +4 -0
- data/LICENSE +27 -0
- data/README.rst +65 -0
- data/Rakefile +6 -0
- data/bin/bond_reconcile.py +385 -0
- data/bin/setup +5 -0
- data/bond.gemspec +36 -0
- data/lib/bond.rb +469 -0
- data/lib/bond/spec_helper.rb +32 -0
- data/lib/bond/targetable.rb +210 -0
- data/lib/bond/version.rb +3 -0
- data/spec/bond_spec.rb +158 -0
- data/spec/bond_targetable_spec.rb +202 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/test_observations/.gitignore +2 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_call_doers_before_returning_result.json +14 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_call_the_function_passed_as_result_if_it_is_callable.json +23 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_a_single_doer_if_filter_criteria_are_met.json +10 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_correctly_call_multiple_doers.json +13 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_not_call_doers_of_overriden_agents.json +8 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_override_old_agents_with_newer_agents.json +0 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_an_exception_if_specified_by_agent.json +9 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_throw_the_result_of_the_value_passed_to_exception_if_callable.json +10 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_should_work_with_multiple_agents_for_different_spy_points.json +23 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_override_old_agents_with_newer_agents_unless_theott8glo1xn.json +22 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_combinations_of_filters.json +37 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_function_filters.json +16 -0
- data/spec/test_observations/bond_spec/Bond_with_agents_with_filters_should_respect_single_key_value_filters_of_all_types.json +121 -0
- data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_nested_hashes_and_arrays_with_hash_sorting.json +31 -0
- data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_with_a_spy_point_name.json +12 -0
- data/spec/test_observations/bond_spec/Bond_without_any_agents_should_correctly_log_some_normal_arguments_without_a_spy_point_name.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_continue_is_returned.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_continues_to_the_method_when_agent_result_none_is_returned.json +14 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_passes_through_blocks.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_returns_nil__and_mocks__when_an_agent_returns_nil.json +11 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_private_methods.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_correctly_spies_protected_methods.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_class_method.json +7 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_method_with_variabl19nhijeqoo.json +13 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_a6qc3d4el92.json +33 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_positional_aott8glo1xn.json +20 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_mix_of_required_andbcgjq06had.json +17 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_a_normal_method.json +7 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_all_optional_keyword_arguments.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_argument_types_correctly_spies_on_variable_keyword_arguments.json +22 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_changes_the_spy_point_naj4gnwvcu8n.json +5 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_errors_when_mocking_is_r9j7wklng0z.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_ignores_excluded_keys.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_mocks_when_one_is_specified.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_w19nhijeqoo.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_different_spy_point_parameters_correctly_spies_the_return_value_ww8esw1qdxc.json +10 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_included_module_methods.json +6 -0
- data/spec/test_observations/bond_targetable_spec/BondTargetable_with_modules_correctly_spies_on_module_methods.json +7 -0
- data/tutorials/binary_search_tree/bst.rb +120 -0
- data/tutorials/binary_search_tree/bst_spec.rb +82 -0
- data/tutorials/binary_search_tree/run_tests.sh +4 -0
- data/tutorials/binary_search_tree/test_observations/.gitignore +2 -0
- data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_with_Bond.json +20 -0
- data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_add_nodes_to_the_BST_correctly__testing_without_Bond.json +3 -0
- data/tutorials/binary_search_tree/test_observations/bst_spec/Node_should_correctly_delete_nodes_from_the_BST.json +29 -0
- data/tutorials/heat_watcher/heat_watcher.rb +107 -0
- data/tutorials/heat_watcher/heat_watcher_spec.rb +116 -0
- data/tutorials/heat_watcher/run_tests.sh +4 -0
- data/tutorials/heat_watcher/test_observations/.gitignore +2 -0
- data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_critical_errors.json +142 -0
- data/tutorials/heat_watcher/test_observations/heat_watcher_spec/HeatWatcher_should_properly_report_warnings_and_switch_back_to_OK_status.json +132 -0
- metadata +211 -0
data/bin/setup
ADDED
data/bond.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require_relative 'lib/bond/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = 'bond-spy'
|
5
|
+
spec.version = Bond::VERSION
|
6
|
+
spec.date = '2016-01-28'
|
7
|
+
|
8
|
+
spec.authors = ['George Necula', 'Erik Krogen']
|
9
|
+
spec.email = ['necula@cs.berkeley.edu', 'erikkrogen@gmail.com']
|
10
|
+
spec.license = 'BSD-2-Clause-FreeBSD'
|
11
|
+
|
12
|
+
spec.summary = 'A spy-based testing framework'
|
13
|
+
spec.homepage = 'http://github.com/necula01/bond'
|
14
|
+
spec.description = <<-EOF
|
15
|
+
Bond is a small library that can be used to spy values and mock functions
|
16
|
+
during tests. Spying is a replacement for writing the assertEquals in your
|
17
|
+
test, which are tedious to write and even more tedious to update when your
|
18
|
+
test setup or code inevitably changes. With Bond, you separate what is
|
19
|
+
being verified, e.g., the variable named output, from what value it should
|
20
|
+
have. This way you can quickly spy several variables, even have structured
|
21
|
+
values such as lists or dictionaries, and these values are saved into an
|
22
|
+
observation log that is saved for future reference. If the test observations
|
23
|
+
are different you have the option to interact with a console or visual tool
|
24
|
+
to see what has changed, and whether the reference set of observations need
|
25
|
+
to be updated.
|
26
|
+
EOF
|
27
|
+
|
28
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }
|
29
|
+
spec.test_files = `git ls-files -z -- spec`.split("\x0")
|
30
|
+
spec.bindir = 'bin'
|
31
|
+
|
32
|
+
spec.required_ruby_version = '>= 2.1'
|
33
|
+
spec.add_development_dependency 'bundler', '~> 1.10'
|
34
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
35
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
36
|
+
end
|
data/lib/bond.rb
ADDED
@@ -0,0 +1,469 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
require 'json'
|
3
|
+
require 'fileutils'
|
4
|
+
require 'shellwords'
|
5
|
+
|
6
|
+
# Singleton class providing the core functionality of Bond. You will generally
|
7
|
+
# access this through the {BondTargetable#bond} method exported to you when you
|
8
|
+
# `extend` BondTargetable, but it can also be accessed (e.g. for functions not
|
9
|
+
# contained in a class/module) via `Bond.instance`.
|
10
|
+
class Bond
|
11
|
+
include Singleton
|
12
|
+
# TODO ETK make this able to use other test frameworks as well
|
13
|
+
|
14
|
+
# Maximum number of characters to allow in any test file name.
|
15
|
+
# File names which would be longer than this will be truncated
|
16
|
+
# to 10 fewer characters than the max, and a hash of the full
|
17
|
+
# name will be appended to uniquely identify the file.
|
18
|
+
MAX_FILE_NAME_LENGTH = 100
|
19
|
+
|
20
|
+
# Returns true if Bond is currently active (in testing mode), else false.
|
21
|
+
# If this returns false, you can safely assume that calls to {#spy} will
|
22
|
+
# have no effect.
|
23
|
+
def active?
|
24
|
+
!@current_test.nil?
|
25
|
+
end
|
26
|
+
|
27
|
+
# Change the settings for Bond, overriding anything which was previously set by
|
28
|
+
# {#start_test}. Accepts any of the keyword arguments that {#start_test} does
|
29
|
+
# except for `test_name`, which cannot be changed.
|
30
|
+
# @param (see #start_test)
|
31
|
+
def settings(spy_groups: nil, observation_directory: nil, reconcile: nil)
|
32
|
+
raise 'not yet implemented' unless spy_groups.nil? # TODO spy_groups
|
33
|
+
@observation_directory = observation_directory unless observation_directory.nil?
|
34
|
+
@reconcile = reconcile unless @reconcile.nil?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Enable testing for Bond. When using RSpec, this will be called automatically
|
38
|
+
# for you when you `include_context :bond`, to which you can pass all of the
|
39
|
+
# same keyword arguments as `start_test`.
|
40
|
+
# @param rspec_test The current test that is being run through RSpec.
|
41
|
+
# @param test_name [String] The name of the current test. If not provided,
|
42
|
+
# the default is the test file concatenated with the full description
|
43
|
+
# string of the RSpec test, e.g. for a test within my_class_spec.rb that
|
44
|
+
# appeared as:
|
45
|
+
#
|
46
|
+
# ```
|
47
|
+
# describe MyClass do
|
48
|
+
# include_context :bond, observation_directory: 'spec/observations'
|
49
|
+
#
|
50
|
+
# context 'when nothing is wrong' do
|
51
|
+
# it 'should work!' do
|
52
|
+
# ... test code ...
|
53
|
+
# # `include_context :bond` automatically exports a `bond` variable
|
54
|
+
# # for you to access for e.g. `spy` and `deploy_agent`
|
55
|
+
# bond.spy('spy_point', key: value, ...)
|
56
|
+
# ... test code ...
|
57
|
+
# end
|
58
|
+
# end
|
59
|
+
# end
|
60
|
+
# ```
|
61
|
+
#
|
62
|
+
# `test_name` would be 'my_class_spec.MyClass_when_nothing_is_wrong_should_work_'
|
63
|
+
# (note that all non-alphanumeric characters except periods are replaced with
|
64
|
+
# an underscore).
|
65
|
+
# @param spy_groups NOT YET IMPLEMENTED.
|
66
|
+
# @param observation_directory [String] Path to the directory where
|
67
|
+
# the observations for this test should be stored. The default is
|
68
|
+
# a 'test_observations' directory located in the same directory
|
69
|
+
# as the file containing the current test. Test observations will
|
70
|
+
# be stored within this directory. Any hierarchy (as specified by .)
|
71
|
+
# in the test name becomes a directory hierarchy, e.g. a test name of
|
72
|
+
# 'bond.my_tests.test_name' would be stored at
|
73
|
+
# '`{observation_directory}`/bond/my_tests/test_name.json'
|
74
|
+
# @param reconcile The action to take when there are differences found between
|
75
|
+
# the reference versions of test output and the current test output.
|
76
|
+
# Should be one of:
|
77
|
+
#
|
78
|
+
# - **`:console`** - interactive console prompts to decide what to merge (default)
|
79
|
+
# - `:abort` - don't accept any new changes
|
80
|
+
# - `:accept` - accept all new changes
|
81
|
+
# - `:kdiff3` - use the kdiff3 graphical merge tool to reconcile differences
|
82
|
+
#
|
83
|
+
def start_test(rspec_test, test_name: nil, spy_groups: nil,
|
84
|
+
observation_directory: nil, reconcile: nil)
|
85
|
+
|
86
|
+
@observations = []
|
87
|
+
@spy_agents = Hash.new { |hash, key|
|
88
|
+
hash[key] = []
|
89
|
+
}
|
90
|
+
@observation_directory = nil
|
91
|
+
@current_test = rspec_test
|
92
|
+
@reconcile = reconcile
|
93
|
+
|
94
|
+
if test_name.nil?
|
95
|
+
test_file = @current_test.metadata[:file_path]
|
96
|
+
# TODO ETK allow other characters besides alphanumeric?
|
97
|
+
@test_name = File.basename(test_file, File.extname(test_file)) + '.' +
|
98
|
+
@current_test.metadata[:full_description].gsub(/[^A-z0-9.]/, '_')
|
99
|
+
else
|
100
|
+
@test_name = test_name
|
101
|
+
end
|
102
|
+
|
103
|
+
settings(spy_groups: spy_groups, observation_directory: observation_directory, reconcile: reconcile)
|
104
|
+
end
|
105
|
+
|
106
|
+
# The main entrypoint, used to observe some program state. If Bond is not active,
|
107
|
+
# does nothing. Observes all keyword arguments within `observation`, recording them
|
108
|
+
# to be written out to a file at the end of the current test. A deep copy
|
109
|
+
# of the arguments is made, all hashes (at any level of nesting) are sorted, and
|
110
|
+
# any object that is not an Array or Hash will have its `Object#clone` method called.
|
111
|
+
# If it is not cloneable (`clone` throws an error), the original object will be used.
|
112
|
+
# The arguments are then JSON-serialized. For any object which has a `to_json` method,
|
113
|
+
# this will be called to serialize it. Note that `to_json` takes one argument which
|
114
|
+
# is a JSON::Ext::Generator::State object; you should pass this object into any `to_json`
|
115
|
+
# calls you make within your `to_json` function.
|
116
|
+
# @param spy_point_name [#to_s] The name of this spy point. Will be used to subsequently
|
117
|
+
# refer to this point for, e.g., {#deploy_agent}. This name also gets printed as
|
118
|
+
# part of the observation with the key `__spy_point__`.
|
119
|
+
# @param observation Keyword arguments which should be observed.
|
120
|
+
# @return if an agent has been set for this spy point, this will return whatever value
|
121
|
+
# is specified by that agent. Otherwise, returns `:agent_result_none`.
|
122
|
+
def spy(spy_point_name=nil, **observation)
|
123
|
+
return :agent_result_none unless active? # If we're not testing, don't do anything
|
124
|
+
|
125
|
+
spy_point_name = spy_point_name.nil? ? nil : spy_point_name.to_s
|
126
|
+
|
127
|
+
observation[:__spy_point__] = spy_point_name unless spy_point_name.nil?
|
128
|
+
observation = deep_clone_sort_hashes(observation)
|
129
|
+
active_agent = spy_point_name.nil? ? nil : @spy_agents[spy_point_name].find { |agent| agent.process?(observation) }
|
130
|
+
|
131
|
+
res = :agent_result_none
|
132
|
+
begin
|
133
|
+
unless active_agent.nil?
|
134
|
+
active_agent.do(observation)
|
135
|
+
res = active_agent.result(observation)
|
136
|
+
end
|
137
|
+
ensure
|
138
|
+
formatted = format_observation(observation, active_agent)
|
139
|
+
@observations <<= formatted
|
140
|
+
#TODO ETK printing
|
141
|
+
puts "Observing: #{formatted} #{", returning <#{res.to_s}>" if res != :agent_result_none}"
|
142
|
+
end
|
143
|
+
|
144
|
+
res
|
145
|
+
end
|
146
|
+
|
147
|
+
# Deploy an agent to watch a specific spy point. The most recently deployed agent
|
148
|
+
# will always take precedence over any previous agents for a given spy point.
|
149
|
+
# However, if the most recent agent does not apply to the current observation
|
150
|
+
# (because its filters do not match), the next most recent agent will be used,
|
151
|
+
# and so on. At most one agent will be applied.
|
152
|
+
# @param spy_point_name [#to_s] The name of the spy point for which to deploy this agent
|
153
|
+
# @param (see SpyAgent#initialize)
|
154
|
+
def deploy_agent(spy_point_name, **opts)
|
155
|
+
raise 'You must enable testing before using deploy_agent' unless active?
|
156
|
+
spy_point_name = spy_point_name.to_s
|
157
|
+
@spy_agents[spy_point_name] = @spy_agents[spy_point_name].unshift(SpyAgent.new(**opts))
|
158
|
+
end
|
159
|
+
|
160
|
+
private
|
161
|
+
|
162
|
+
# Clean up from the current test. Marks Bond as no longer active, and saves
|
163
|
+
# all of the current observations to a file specified by {#observation_file_name}.
|
164
|
+
# Creates whatever directory structure necessary for this, and adds a .gitignore
|
165
|
+
# file to ignore Bond's temporary output (if one does not already exist). Then begins
|
166
|
+
# the reconciliation process. If the new observations are different
|
167
|
+
# from the reference files, reconciles them using whatever means specified
|
168
|
+
# by the `reconcile` setting.
|
169
|
+
# @return If the test failed, returns `:test_fail`. If reconciliation fails
|
170
|
+
# (new changes are not accepted), returns `:bond_fail`. Else returns `:pass`
|
171
|
+
def finish_test
|
172
|
+
fname = observation_file_name
|
173
|
+
fdir = File.dirname(fname)
|
174
|
+
unless File.directory?(fdir)
|
175
|
+
FileUtils.mkdir_p(fdir)
|
176
|
+
top_git_ignore = File.join(observation_directory, '.gitignore')
|
177
|
+
unless File.file?(top_git_ignore)
|
178
|
+
# TODO ETK make this configurable in case you don't use git?
|
179
|
+
File.open(top_git_ignore, 'w') do |outfile|
|
180
|
+
outfile.print("*_now.json\n*.diff\n")
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
test_fail = !@current_test.exception.nil?
|
186
|
+
|
187
|
+
ref_file = fname + '.json'
|
188
|
+
cur_file = fname + '_now.json'
|
189
|
+
File.delete(cur_file) if File.exists?(cur_file)
|
190
|
+
save_observations(cur_file)
|
191
|
+
|
192
|
+
reconcile_result = reconcile_observations(ref_file, cur_file,
|
193
|
+
test_fail ? 'Test had failure(s)!' : nil)
|
194
|
+
return :test_fail if test_fail
|
195
|
+
return reconcile_result
|
196
|
+
ensure
|
197
|
+
@current_test = nil
|
198
|
+
end
|
199
|
+
|
200
|
+
# Reconcile observations, for now using an external Python script.
|
201
|
+
# Depending on the `reconcile` setting, will take action to reconcile the differences.
|
202
|
+
# @param ref_file [String] Path to the accepted/reference test output.
|
203
|
+
# If this does not exist, it will be treated as an empty file.
|
204
|
+
# @param cur_file [String] Path to the current test output.
|
205
|
+
# @param no_save [nil, String] If not `nil`, `ref_file` will *not* be overwritten
|
206
|
+
# and the string will be displayed as the reason why saving is not allowed.
|
207
|
+
# @return `:pass` if the reconciliation succeeds, else `:bond_fail`
|
208
|
+
def reconcile_observations(ref_file, cur_file, no_save=nil)
|
209
|
+
bond_reconcile_script = File.absolute_path(File.join(File.dirname(File.dirname(__FILE__)), 'bin', 'bond_reconcile.py'))
|
210
|
+
unless File.exists?(bond_reconcile_script)
|
211
|
+
raise "Cannot find the bond_reconcile script: #{bond_reconcile_script}"
|
212
|
+
end
|
213
|
+
|
214
|
+
cmd = "#{Shellwords.shellescape(bond_reconcile_script)} " +
|
215
|
+
"--reference #{Shellwords.shellescape(ref_file)} " +
|
216
|
+
"--current #{Shellwords.shellescape(cur_file)} " +
|
217
|
+
"--test #{Shellwords.shellescape(@test_name)} " +
|
218
|
+
(@reconcile.nil? ? '' : "--reconcile #{@reconcile.to_s}") +
|
219
|
+
(no_save.nil? ? '' : "--no-save #{Shellwords.shellescape(no_save.to_s)}")
|
220
|
+
puts "Running: #{cmd}"
|
221
|
+
code = system(cmd)
|
222
|
+
code ? :pass : :bond_fail
|
223
|
+
end
|
224
|
+
|
225
|
+
# Save all current observations to a file. Assumes that `@observations`
|
226
|
+
# has already been JSON-serialized and outputs them all as a JSON array.
|
227
|
+
# @param fname [String] Path where the file should be saved.
|
228
|
+
def save_observations(fname)
|
229
|
+
File.open(fname, 'w') do |f|
|
230
|
+
f.print("[\n#{@observations.join(",\n")}\n]\n")
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# Return the file name where observations for the current test should be
|
235
|
+
# stored. Any hierarchy (as specified by .) in the test name becomes
|
236
|
+
# a directory hierarchy, e.g. a test name of 'bond.my_tests.test_name'
|
237
|
+
# would be stored at '`{base_directory}`/bond/my_tests/test_name.json'
|
238
|
+
# If any portion of the file name (i.e. a directory or file name) is
|
239
|
+
# longer than {#MAX_FILE_NAME_LENGTH} - 5 (to account for a possible
|
240
|
+
# `.json` extension), reduce the length to 10 characters less than
|
241
|
+
# this and fill the remaining 10 characters with a hash of the full name.
|
242
|
+
def observation_file_name
|
243
|
+
name_array = @test_name.split('.').map do |name|
|
244
|
+
if name.length <= MAX_FILE_NAME_LENGTH - 5
|
245
|
+
name
|
246
|
+
else
|
247
|
+
# Using djb2 hash algorithm translated from http://www.cse.yorku.ca/~oz/hash.html
|
248
|
+
name_hash = name.chars.inject(5381) { |sum, c| ((sum << 5) + sum) + c.to_i }
|
249
|
+
# Take start of name, up to first 10 chars of the hash as base 36 (alphanumerics)
|
250
|
+
name[0, MAX_FILE_NAME_LENGTH - 15] + name_hash.to_s(36)[0, 10]
|
251
|
+
end
|
252
|
+
end
|
253
|
+
File.join(observation_directory, name_array)
|
254
|
+
end
|
255
|
+
|
256
|
+
# Return the directory where observations should be stored
|
257
|
+
# This can be specified with the `observation_directory` setting.
|
258
|
+
# If not set, it will be a 'test_observations' directory located
|
259
|
+
# in the same directory as the file containing the current test.
|
260
|
+
def observation_directory
|
261
|
+
return @observation_directory unless @observation_directory.nil?
|
262
|
+
test_file = @current_test.metadata[:file_path]
|
263
|
+
File.join(File.dirname(File.absolute_path(test_file)), 'test_observations')
|
264
|
+
end
|
265
|
+
|
266
|
+
# Formats the observation hash. Currently, this just JSON-serializes
|
267
|
+
# the hash. If any objects encountered have a `to_json` method, it will
|
268
|
+
# be called to serialize the object. Note that `to_json` takes one argument
|
269
|
+
# which is a JSON::Ext::Generator::State object; you should pass this object
|
270
|
+
# into any `to_json` calls you make within your `to_json` function.
|
271
|
+
# @param observation [Hash] Observations to be formatted.
|
272
|
+
# @return The formatted hash.
|
273
|
+
def format_observation(observation, agent = nil)
|
274
|
+
# TODO ETK actually have formatters
|
275
|
+
JSON.pretty_generate(observation, indent: ' '*4)
|
276
|
+
end
|
277
|
+
|
278
|
+
# Deep-clones an object while sorting any Hashes at any depth:
|
279
|
+
#
|
280
|
+
# - Hash: Creates a new hash containing all of the old key-value
|
281
|
+
# pairs sorted by key
|
282
|
+
# - Array: Creates a new array with the old contents *not* sorted
|
283
|
+
# - Other: Attempts to call Object#clone. If this fails (results in #TypeError)
|
284
|
+
# then the object is returned as-is (assumes that non-cloneable objects
|
285
|
+
# are immutable and thus don't need cloning)
|
286
|
+
#
|
287
|
+
# @param obj The object to be cloned.
|
288
|
+
# @return The deep-clone with hashes sorted.
|
289
|
+
def deep_clone_sort_hashes(obj)
|
290
|
+
if obj.is_a?(Hash)
|
291
|
+
{}.tap do |new|
|
292
|
+
obj.sort.each do |k, v|
|
293
|
+
new[k] = deep_clone_sort_hashes(v)
|
294
|
+
end
|
295
|
+
end
|
296
|
+
elsif obj.is_a?(Array) # Don't sort arrays, just clone
|
297
|
+
obj.map { |x| deep_clone_sort_hashes(x) }
|
298
|
+
else
|
299
|
+
begin
|
300
|
+
obj.clone
|
301
|
+
rescue TypeError # Some types, e.g. Fixnum and Symbol, can't be cloned
|
302
|
+
obj
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
end
|
308
|
+
|
309
|
+
# Represents an agent deployed by {Bond#deploy_agent}. Takes
|
310
|
+
# action on spy points depending on the options specified upon
|
311
|
+
# initialization.
|
312
|
+
# @api private
|
313
|
+
class SpyAgent
|
314
|
+
|
315
|
+
# Initialize, setting the options for this SpyAgent.
|
316
|
+
# @param opts Key-value pairs that control whether the agent is
|
317
|
+
# active and what it does. The following keys are recognized:
|
318
|
+
#
|
319
|
+
# - Keys that restrict for which invocations of bond.spy this
|
320
|
+
# agent is active. All of these conditions must be true for
|
321
|
+
# the agent to be the active one:
|
322
|
+
#
|
323
|
+
# - `key: val` - only when the observation dictionary contains
|
324
|
+
# the `key` with the given value
|
325
|
+
# - `key__contains: substr` - only when the observation dictionary
|
326
|
+
# contains the `key` with a string value that contains the given substr.
|
327
|
+
# - `key__startswith: substr` - only when the observation dictionary
|
328
|
+
# contains the `key` with a string value that starts with the given substr.
|
329
|
+
# - `key__endswith: substr` - only when the observation dictionary contains
|
330
|
+
# the `key` with a string value that ends with the given substr.
|
331
|
+
# - `filter: func` - only when the given func returns true when passed
|
332
|
+
# observation dictionary. The function should not make changes to
|
333
|
+
# the observation dictionary. Uses the observation before formatting.
|
334
|
+
#
|
335
|
+
# - Keys that control what the observer does when processed:
|
336
|
+
#
|
337
|
+
# - `do: func` - executes the given function with the observation dictionary.
|
338
|
+
# func can also be a list of functions, executed in order.
|
339
|
+
# The function should not make changes to the observation dictionary.
|
340
|
+
# Uses the observation before formatting.
|
341
|
+
#
|
342
|
+
# - Keys that control what the corresponding spy returns (by default `:agent_result_none`):
|
343
|
+
#
|
344
|
+
# - `exception: x` - the call to bond.spy throws the given exception. If `x`
|
345
|
+
# is a function it is invoked on the observation dictionary to compute
|
346
|
+
# the exception to throw. The function should not make changes to the
|
347
|
+
# observation dictionary. Uses the observation before formatting.
|
348
|
+
# - `result: x` - the call to bond.spy returns the given value. If `x` is a
|
349
|
+
# function it is invoked on the observe argument dictionary to compute
|
350
|
+
# the value to return. If the function throws an exception then the
|
351
|
+
# spied function throws an exception. The function should not make
|
352
|
+
# changes to the observation dictionary. Uses the observation before
|
353
|
+
# formatting.
|
354
|
+
#
|
355
|
+
# - Keys that control how the observation is saved. This is processed after all
|
356
|
+
# the above functions. **NOT YET AVAILABLE**
|
357
|
+
#
|
358
|
+
# - `formatter: func` - If specified, a function that is given the observation and
|
359
|
+
# can update it in place. The formatted observation is what gets serialized and saved.
|
360
|
+
#
|
361
|
+
def initialize(**opts)
|
362
|
+
# TODO ETK needs formatters
|
363
|
+
@result_spec = :agent_result_none
|
364
|
+
@exception_spec = nil
|
365
|
+
@doers = []
|
366
|
+
@filters = []
|
367
|
+
|
368
|
+
opts.each do |k, v|
|
369
|
+
case k.to_s # Convert to string in case it was passed as a symbol
|
370
|
+
when 'result'
|
371
|
+
@result_spec = v
|
372
|
+
when 'exception'
|
373
|
+
@exception_spec = v
|
374
|
+
when 'do'
|
375
|
+
@doers = [*v]
|
376
|
+
else # Must be a filter
|
377
|
+
@filters <<= SpyAgentFilter.new(k.to_s, v)
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
# Checks if this agent should process this observation
|
383
|
+
# @return true iff this agent should process this observation
|
384
|
+
def process?(observation)
|
385
|
+
@filters.empty? || @filters.all? do |filter|
|
386
|
+
filter.accept?(observation)
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
# Carries out all of the actions specified by the `do` option
|
391
|
+
def do(observation)
|
392
|
+
@doers.each { |doer| doer.call(observation) }
|
393
|
+
end
|
394
|
+
|
395
|
+
# Gets the result that this agent should return, dependent on the
|
396
|
+
# `result` and `exception` options. If neither is present,
|
397
|
+
# returns `:agent_result_none`.
|
398
|
+
def result(observation)
|
399
|
+
unless @exception_spec.nil?
|
400
|
+
raise @exception_spec.respond_to?(:call) ? @exception_spec.call(observation) : @exception_spec
|
401
|
+
end
|
402
|
+
|
403
|
+
if !@result_spec.nil? && @result_spec.respond_to?(:call)
|
404
|
+
@result_spec.call(observation)
|
405
|
+
else
|
406
|
+
@result_spec
|
407
|
+
end
|
408
|
+
end
|
409
|
+
end
|
410
|
+
|
411
|
+
# Filters used to determine whether or not a {SpyAgent} should
|
412
|
+
# be applied to a given observation.
|
413
|
+
# @api private
|
414
|
+
class SpyAgentFilter
|
415
|
+
|
416
|
+
# Initialize this filter.
|
417
|
+
# @see Bond#deploy_agent
|
418
|
+
# @param filter_key [#to_s] The key for the filter.
|
419
|
+
# @param filter_value The value for the filter.
|
420
|
+
def initialize(filter_key, filter_value)
|
421
|
+
@field_name = nil
|
422
|
+
@filter_func = nil
|
423
|
+
|
424
|
+
filter_key = filter_key.to_s
|
425
|
+
if filter_key == 'filter'
|
426
|
+
raise 'When using filter, passed value must be callable' unless filter_value.respond_to?(:call)
|
427
|
+
@filter_func = filter_value
|
428
|
+
return
|
429
|
+
end
|
430
|
+
|
431
|
+
key_parts = filter_key.split('__')
|
432
|
+
if key_parts.length == 1
|
433
|
+
@field_name = key_parts[0]
|
434
|
+
@filter_func = lambda { |val| val == filter_value }
|
435
|
+
elsif key_parts.length == 2
|
436
|
+
@field_name = key_parts[0]
|
437
|
+
case key_parts[1]
|
438
|
+
when 'exact','eq'
|
439
|
+
@filter_func = lambda { |val| val == filter_value }
|
440
|
+
when 'startswith'
|
441
|
+
@filter_func = lambda { |val| val.start_with?(filter_value) }
|
442
|
+
when 'endswith'
|
443
|
+
@filter_func = lambda { |val| val.end_with?(filter_value) }
|
444
|
+
when 'contains'
|
445
|
+
@filter_func = lambda { |val| val.include?(filter_value) }
|
446
|
+
else
|
447
|
+
raise "Unknown operator: #{key_parts[1]}"
|
448
|
+
end
|
449
|
+
else
|
450
|
+
raise "Invalid key passed in: #{filter_key}"
|
451
|
+
end
|
452
|
+
end
|
453
|
+
|
454
|
+
# Return true iff the provided observation meets this filter.
|
455
|
+
def accept?(observation)
|
456
|
+
if @field_name.nil?
|
457
|
+
@filter_func.call(observation)
|
458
|
+
elsif observation.has_key?(@field_name)
|
459
|
+
@filter_func.call(observation[@field_name])
|
460
|
+
elsif observation.has_key?(@field_name.to_sym)
|
461
|
+
@filter_func.call(observation[@field_name.to_sym])
|
462
|
+
else
|
463
|
+
false
|
464
|
+
end
|
465
|
+
end
|
466
|
+
|
467
|
+
end
|
468
|
+
|
469
|
+
require_relative 'bond/targetable'
|